agora inbox for [email protected]
help / color / mirror / Atom feed[PATCH v2 1/7] Row pattern recognition patch for raw parser.
152+ messages / 2 participants
[nested] [flat]
* [PATCH v2 1/7] Row pattern recognition patch for raw parser.
@ 2023-06-26 08:05 Tatsuo Ishii <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Tatsuo Ishii @ 2023-06-26 08:05 UTC (permalink / raw)
---
src/backend/parser/gram.y | 218 +++++++++++++++++++++++++++++---
src/include/nodes/parsenodes.h | 54 ++++++++
src/include/parser/kwlist.h | 8 ++
src/include/parser/parse_node.h | 1 +
4 files changed, 266 insertions(+), 15 deletions(-)
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 39ab7eac0d..5cd3ebaa98 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -251,6 +251,8 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
DefElem *defelt;
SortBy *sortby;
WindowDef *windef;
+ RPCommonSyntax *rpcom;
+ RPSubsetItem *rpsubset;
JoinExpr *jexpr;
IndexElem *ielem;
StatsElem *selem;
@@ -453,8 +455,12 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
TriggerTransitions TriggerReferencing
vacuum_relation_list opt_vacuum_relation_list
drop_option_list pub_obj_list
-
-%type <node> opt_routine_body
+ row_pattern_measure_list row_pattern_definition_list
+ opt_row_pattern_subset_clause
+ row_pattern_subset_list row_pattern_subset_rhs
+ row_pattern
+%type <rpsubset> row_pattern_subset_item
+%type <node> opt_routine_body row_pattern_term
%type <groupclause> group_clause
%type <list> group_by_list
%type <node> group_by_item empty_grouping_set rollup_clause cube_clause
@@ -551,6 +557,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
%type <range> relation_expr_opt_alias
%type <node> tablesample_clause opt_repeatable_clause
%type <target> target_el set_target insert_column_item
+ row_pattern_measure_item row_pattern_definition
%type <str> generic_option_name
%type <node> generic_option_arg
@@ -633,6 +640,9 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
%type <list> window_clause window_definition_list opt_partition_clause
%type <windef> window_definition over_clause window_specification
opt_frame_clause frame_extent frame_bound
+%type <rpcom> opt_row_pattern_common_syntax opt_row_pattern_skip_to
+%type <boolean> opt_row_pattern_initial_or_seek
+%type <list> opt_row_pattern_measures
%type <ival> opt_window_exclusion_clause
%type <str> opt_existing_window_name
%type <boolean> opt_if_not_exists
@@ -645,7 +655,6 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
%type <list> hash_partbound
%type <defelt> hash_partbound_elem
-
%type <node> json_format_clause_opt
json_value_expr
json_output_clause_opt
@@ -705,7 +714,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURSOR CYCLE
DATA_P DATABASE DAY_P DEALLOCATE DEC DECIMAL_P DECLARE DEFAULT DEFAULTS
- DEFERRABLE DEFERRED DEFINER DELETE_P DELIMITER DELIMITERS DEPENDS DEPTH DESC
+ DEFERRABLE DEFERRED DEFINE DEFINER DELETE_P DELIMITER DELIMITERS DEPENDS DEPTH DESC
DETACH DICTIONARY DISABLE_P DISCARD DISTINCT DO DOCUMENT_P DOMAIN_P
DOUBLE_P DROP
@@ -721,7 +730,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
HANDLER HAVING HEADER_P HOLD HOUR_P
IDENTITY_P IF_P ILIKE IMMEDIATE IMMUTABLE IMPLICIT_P IMPORT_P IN_P INCLUDE
- INCLUDING INCREMENT INDENT INDEX INDEXES INHERIT INHERITS INITIALLY INLINE_P
+ INCLUDING INCREMENT INDENT INDEX INDEXES INHERIT INHERITS INITIAL INITIALLY INLINE_P
INNER_P INOUT INPUT_P INSENSITIVE INSERT INSTEAD INT_P INTEGER
INTERSECT INTERVAL INTO INVOKER IS ISNULL ISOLATION
@@ -733,7 +742,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
LEADING LEAKPROOF LEAST LEFT LEVEL LIKE LIMIT LISTEN LOAD LOCAL
LOCALTIME LOCALTIMESTAMP LOCATION LOCK_P LOCKED LOGGED
- MAPPING MATCH MATCHED MATERIALIZED MAXVALUE MERGE METHOD
+ MAPPING MATCH MATCHED MATERIALIZED MAXVALUE MEASURES MERGE METHOD
MINUTE_P MINVALUE MODE MONTH_P MOVE
NAME_P NAMES NATIONAL NATURAL NCHAR NEW NEXT NFC NFD NFKC NFKD NO NONE
@@ -745,9 +754,9 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
ORDER ORDINALITY OTHERS OUT_P OUTER_P
OVER OVERLAPS OVERLAY OVERRIDING OWNED OWNER
- PARALLEL PARAMETER PARSER PARTIAL PARTITION PASSING PASSWORD
- PLACING PLANS POLICY
- POSITION PRECEDING PRECISION PRESERVE PREPARE PREPARED PRIMARY
+ PARALLEL PARAMETER PARSER PARTIAL PARTITION PASSING PASSWORD PAST
+ PATTERN PLACING PLANS POLICY
+ POSITION PRECEDING PRECISION PREMUTE PRESERVE PREPARE PREPARED PRIMARY
PRIOR PRIVILEGES PROCEDURAL PROCEDURE PROCEDURES PROGRAM PUBLICATION
QUOTE
@@ -757,12 +766,13 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
RESET RESTART RESTRICT RETURN RETURNING RETURNS REVOKE RIGHT ROLE ROLLBACK ROLLUP
ROUTINE ROUTINES ROW ROWS RULE
- SAVEPOINT SCALAR SCHEMA SCHEMAS SCROLL SEARCH SECOND_P SECURITY SELECT
+ SAVEPOINT SCALAR SCHEMA SCHEMAS SCROLL SEARCH SECOND_P SECURITY SEEK SELECT
SEQUENCE SEQUENCES
+
SERIALIZABLE SERVER SESSION SESSION_USER SET SETS SETOF SHARE SHOW
SIMILAR SIMPLE SKIP SMALLINT SNAPSHOT SOME SQL_P STABLE STANDALONE_P
START STATEMENT STATISTICS STDIN STDOUT STORAGE STORED STRICT_P STRIP_P
- SUBSCRIPTION SUBSTRING SUPPORT SYMMETRIC SYSID SYSTEM_P SYSTEM_USER
+ SUBSCRIPTION SUBSET SUBSTRING SUPPORT SYMMETRIC SYSID SYSTEM_P SYSTEM_USER
TABLE TABLES TABLESAMPLE TABLESPACE TEMP TEMPLATE TEMPORARY TEXT_P THEN
TIES TIME TIMESTAMP TO TRAILING TRANSACTION TRANSFORM
@@ -855,6 +865,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
*/
%nonassoc UNBOUNDED /* ideally would have same precedence as IDENT */
%nonassoc IDENT PARTITION RANGE ROWS GROUPS PRECEDING FOLLOWING CUBE ROLLUP
+%nonassoc MEASURES AFTER INITIAL SEEK PATTERN
%left Op OPERATOR /* multi-character ops and user-defined operators */
%left '+' '-'
%left '*' '/' '%'
@@ -15773,7 +15784,8 @@ over_clause: OVER window_specification
;
window_specification: '(' opt_existing_window_name opt_partition_clause
- opt_sort_clause opt_frame_clause ')'
+ opt_sort_clause opt_row_pattern_measures opt_frame_clause
+ opt_row_pattern_common_syntax ')'
{
WindowDef *n = makeNode(WindowDef);
@@ -15781,10 +15793,12 @@ window_specification: '(' opt_existing_window_name opt_partition_clause
n->refname = $2;
n->partitionClause = $3;
n->orderClause = $4;
+ n->rowPatternMeasures = $5;
/* copy relevant fields of opt_frame_clause */
- n->frameOptions = $5->frameOptions;
- n->startOffset = $5->startOffset;
- n->endOffset = $5->endOffset;
+ n->frameOptions = $6->frameOptions;
+ n->startOffset = $6->startOffset;
+ n->endOffset = $6->endOffset;
+ n->rpCommonSyntax = $7;
n->location = @1;
$$ = n;
}
@@ -15808,6 +15822,31 @@ opt_partition_clause: PARTITION BY expr_list { $$ = $3; }
| /*EMPTY*/ { $$ = NIL; }
;
+/*
+ * ROW PATTERN MEASURES
+ */
+opt_row_pattern_measures: MEASURES row_pattern_measure_list { $$ = $2; }
+ | /*EMPTY*/ { $$ = NIL; }
+ ;
+
+row_pattern_measure_list:
+ row_pattern_measure_item
+ { $$ = list_make1($1); }
+ | row_pattern_measure_list ',' row_pattern_measure_item
+ { $$ = lappend($1, $3); }
+ ;
+
+row_pattern_measure_item:
+ a_expr AS ColLabel
+ {
+ $$ = makeNode(ResTarget);
+ $$->name = $3;
+ $$->indirection = NIL;
+ $$->val = (Node *) $1;
+ $$->location = @1;
+ }
+ ;
+
/*
* For frame clauses, we return a WindowDef, but only some fields are used:
* frameOptions, startOffset, and endOffset.
@@ -15967,6 +16006,139 @@ opt_window_exclusion_clause:
| /*EMPTY*/ { $$ = 0; }
;
+opt_row_pattern_common_syntax:
+opt_row_pattern_skip_to opt_row_pattern_initial_or_seek
+ PATTERN '(' row_pattern ')'
+ opt_row_pattern_subset_clause
+ DEFINE row_pattern_definition_list
+ {
+ RPCommonSyntax *n = makeNode(RPCommonSyntax);
+ n->rpSkipTo = $1->rpSkipTo;
+ n->rpSkipVariable = $1->rpSkipVariable;
+ n->initial = $2;
+ n->rpPatterns = $5;
+ n->rpSubsetClause = $7;
+ n->rpDefs = $9;
+ $$ = n;
+ }
+ | /*EMPTY*/ { $$ = NULL; }
+ ;
+
+opt_row_pattern_skip_to:
+ AFTER MATCH SKIP TO NEXT ROW
+ {
+ RPCommonSyntax *n = makeNode(RPCommonSyntax);
+ n->rpSkipTo = ST_NEXT_ROW;
+ n->rpSkipVariable = NULL;
+ $$ = n;
+ }
+ | AFTER MATCH SKIP PAST LAST_P ROW
+ {
+ RPCommonSyntax *n = makeNode(RPCommonSyntax);
+ n->rpSkipTo = ST_PAST_LAST_ROW;
+ n->rpSkipVariable = NULL;
+ $$ = n;
+ }
+/*
+ | AFTER MATCH SKIP TO FIRST_P ColId %prec FIRST_P
+ {
+ RPCommonSyntax *n = makeNode(RPCommonSyntax);
+ n->rpSkipTo = ST_FIRST_VARIABLE;
+ n->rpSkipVariable = $6;
+ $$ = n;
+ }
+ | AFTER MATCH SKIP TO LAST_P ColId %prec LAST_P
+ {
+ RPCommonSyntax *n = makeNode(RPCommonSyntax);
+ n->rpSkipTo = ST_LAST_VARIABLE;
+ n->rpSkipVariable = $6;
+ $$ = n;
+ }
+ * Shift/reduce
+ | AFTER MATCH SKIP TO ColId
+ {
+ RPCommonSyntax *n = makeNode(RPCommonSyntax);
+ n->rpSkipTo = ST_VARIABLE;
+ n->rpSkipVariable = $5;
+ $$ = n;
+ }
+*/
+ | /*EMPTY*/
+ {
+ RPCommonSyntax *n = makeNode(RPCommonSyntax);
+ /* temporary set default to ST_NEXT_ROW */
+ n->rpSkipTo = ST_PAST_LAST_ROW;
+ n->rpSkipVariable = NULL;
+ $$ = n;
+ }
+ ;
+
+opt_row_pattern_initial_or_seek:
+ INITIAL { $$ = true; }
+ | SEEK
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("SEEK is not supported"),
+ errhint("Use INITIAL."),
+ parser_errposition(@1)));
+ }
+ | /*EMPTY*/ { $$ = true; }
+ ;
+
+row_pattern:
+ row_pattern_term { $$ = list_make1($1); }
+ | row_pattern row_pattern_term { $$ = lappend($1, $2); }
+ ;
+
+row_pattern_term:
+ ColId { $$ = (Node *) makeSimpleA_Expr(AEXPR_OP, "", (Node *)makeString($1), NULL, @1); }
+ | ColId '*' { $$ = (Node *) makeSimpleA_Expr(AEXPR_OP, "*", (Node *)makeString($1), NULL, @1); }
+ | ColId '+' { $$ = (Node *) makeSimpleA_Expr(AEXPR_OP, "+", (Node *)makeString($1), NULL, @1); }
+ | ColId '?' { $$ = (Node *) makeSimpleA_Expr(AEXPR_OP, "?", (Node *)makeString($1), NULL, @1); }
+ ;
+
+opt_row_pattern_subset_clause:
+ SUBSET row_pattern_subset_list { $$ = $2; }
+ | /*EMPTY*/ { $$ = NIL; }
+ ;
+
+row_pattern_subset_list:
+ row_pattern_subset_item { $$ = list_make1($1); }
+ | row_pattern_subset_list ',' row_pattern_subset_item { $$ = lappend($1, $3); }
+ | /*EMPTY*/ { $$ = NIL; }
+ ;
+
+row_pattern_subset_item: ColId '=' '(' row_pattern_subset_rhs ')'
+ {
+ RPSubsetItem *n = makeNode(RPSubsetItem);
+ n->name = $1;
+ n->rhsVariable = $4;
+ $$ = n;
+ }
+ ;
+
+row_pattern_subset_rhs:
+ ColId { $$ = list_make1(makeStringConst($1, @1)); }
+ | row_pattern_subset_rhs ',' ColId { $$ = lappend($1, makeStringConst($3, @1)); }
+ | /*EMPTY*/ { $$ = NIL; }
+ ;
+
+row_pattern_definition_list:
+ row_pattern_definition { $$ = list_make1($1); }
+ | row_pattern_definition_list ',' row_pattern_definition { $$ = lappend($1, $3); }
+ ;
+
+row_pattern_definition:
+ ColId AS a_expr
+ {
+ $$ = makeNode(ResTarget);
+ $$->name = $1;
+ $$->indirection = NIL;
+ $$->val = (Node *) $3;
+ $$->location = @1;
+ }
+ ;
/*
* Supporting nonterminals for expressions.
@@ -17060,6 +17232,7 @@ unreserved_keyword:
| INDEXES
| INHERIT
| INHERITS
+ | INITIAL
| INLINE_P
| INPUT_P
| INSENSITIVE
@@ -17088,6 +17261,7 @@ unreserved_keyword:
| MATCHED
| MATERIALIZED
| MAXVALUE
+ | MEASURES
| MERGE
| METHOD
| MINUTE_P
@@ -17130,9 +17304,12 @@ unreserved_keyword:
| PARTITION
| PASSING
| PASSWORD
+ | PAST
+ | PATTERN
| PLANS
| POLICY
| PRECEDING
+ | PREMUTE
| PREPARE
| PREPARED
| PRESERVE
@@ -17180,6 +17357,7 @@ unreserved_keyword:
| SEARCH
| SECOND_P
| SECURITY
+ | SEEK
| SEQUENCE
| SEQUENCES
| SERIALIZABLE
@@ -17205,6 +17383,7 @@ unreserved_keyword:
| STRICT_P
| STRIP_P
| SUBSCRIPTION
+ | SUBSET
| SUPPORT
| SYSID
| SYSTEM_P
@@ -17389,6 +17568,7 @@ reserved_keyword:
| CURRENT_USER
| DEFAULT
| DEFERRABLE
+ | DEFINE
| DESC
| DISTINCT
| DO
@@ -17551,6 +17731,7 @@ bare_label_keyword:
| DEFAULTS
| DEFERRABLE
| DEFERRED
+ | DEFINE
| DEFINER
| DELETE_P
| DELIMITER
@@ -17626,6 +17807,7 @@ bare_label_keyword:
| INDEXES
| INHERIT
| INHERITS
+ | INITIAL
| INITIALLY
| INLINE_P
| INNER_P
@@ -17673,6 +17855,7 @@ bare_label_keyword:
| MATCHED
| MATERIALIZED
| MAXVALUE
+ | MEASURES
| MERGE
| METHOD
| MINVALUE
@@ -17726,11 +17909,14 @@ bare_label_keyword:
| PARTITION
| PASSING
| PASSWORD
+ | PAST
+ | PATTERN
| PLACING
| PLANS
| POLICY
| POSITION
| PRECEDING
+ | PREMUTE
| PREPARE
| PREPARED
| PRESERVE
@@ -17782,6 +17968,7 @@ bare_label_keyword:
| SCROLL
| SEARCH
| SECURITY
+ | SEEK
| SELECT
| SEQUENCE
| SEQUENCES
@@ -17813,6 +18000,7 @@ bare_label_keyword:
| STRICT_P
| STRIP_P
| SUBSCRIPTION
+ | SUBSET
| SUBSTRING
| SUPPORT
| SYMMETRIC
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index b3bec90e52..22e1a01a0e 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -548,6 +548,44 @@ typedef struct SortBy
int location; /* operator location, or -1 if none/unknown */
} SortBy;
+/*
+ * AFTER MATCH row pattern skip to> types in row pattern common syntax
+ */
+typedef enum RPSkipTo
+{
+ ST_NONE, /* AFTER MATCH omitted */
+ ST_NEXT_ROW, /* SKIP TO NEXT ROW */
+ ST_PAST_LAST_ROW, /* SKIP TO PAST LAST ROW */
+ ST_FIRST_VARIABLE, /* SKIP TO FIRST variable name */
+ ST_LAST_VARIABLE, /* SKIP TO LAST variable name */
+ ST_VARIABLE /* SKIP TO variable name */
+} RPSkipTo;
+
+/*
+ * Row Pattern SUBSET clause item
+ */
+typedef struct RPSubsetItem
+{
+ NodeTag type;
+ char *name; /* Row Pattern SUBSET clause variable name */
+ List *rhsVariable; /* Row Pattern SUBSET rhs variables (list of char *string) */
+} RPSubsetItem;
+
+/*
+ * RowPatternCommonSyntax - raw representation of row pattern common syntax
+ *
+ */
+typedef struct RPCommonSyntax
+{
+ NodeTag type;
+ RPSkipTo rpSkipTo; /* Row Pattern AFTER MATCH SKIP type */
+ char *rpSkipVariable; /* Row Pattern Skip To variable name, if any */
+ bool initial; /* true if <row pattern initial or seek> is initial */
+ List *rpPatterns; /* PATTERN variables (list of A_Expr) */
+ List *rpSubsetClause; /* row pattern subset clause (list of RPSubsetItem), if any */
+ List *rpDefs; /* row pattern definitions clause (list of ResTarget) */
+} RPCommonSyntax;
+
/*
* WindowDef - raw representation of WINDOW and OVER clauses
*
@@ -563,6 +601,8 @@ typedef struct WindowDef
char *refname; /* referenced window name, if any */
List *partitionClause; /* PARTITION BY expression list */
List *orderClause; /* ORDER BY (list of SortBy) */
+ List *rowPatternMeasures; /* row pattern measures (list of ResTarget) */
+ RPCommonSyntax *rpCommonSyntax; /* row pattern common syntax */
int frameOptions; /* frame_clause options, see below */
Node *startOffset; /* expression for starting bound, if any */
Node *endOffset; /* expression for ending bound, if any */
@@ -1482,6 +1522,11 @@ typedef struct GroupingSet
* the orderClause might or might not be copied (see copiedOrder); the framing
* options are never copied, per spec.
*
+ * "defineClause" is Row Pattern Recognition DEFINE clause (list of
+ * TargetEntry). TargetEntry.resname represents row pattern definition
+ * variable name. "patternVariable" and "patternRegexp" represents PATTERN
+ * clause.
+ *
* The information relevant for the query jumbling is the partition clause
* type and its bounds.
*/
@@ -1513,6 +1558,15 @@ typedef struct WindowClause
Index winref; /* ID referenced by window functions */
/* did we copy orderClause from refname? */
bool copiedOrder pg_node_attr(query_jumble_ignore);
+ /* Row Pattern Recognition AFTER MACH SKIP clause */
+ RPSkipTo rpSkipTo; /* Row Pattern Skip To type */
+ bool initial; /* true if <row pattern initial or seek> is initial */
+ /* Row Pattern Recognition DEFINE clause (list of TargetEntry) */
+ List *defineClause;
+ /* Row Pattern Recognition PATTERN variable name (list of String) */
+ List *patternVariable;
+ /* Row Pattern Recognition PATTERN regular expression quantifier ('+' or ''. list of String) */
+ List *patternRegexp;
} WindowClause;
/*
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index f5b2e61ca5..12603b311c 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -128,6 +128,7 @@ PG_KEYWORD("default", DEFAULT, RESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("defaults", DEFAULTS, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("deferrable", DEFERRABLE, RESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("deferred", DEFERRED, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("define", DEFINE, RESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("definer", DEFINER, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("delete", DELETE_P, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("delimiter", DELIMITER, UNRESERVED_KEYWORD, BARE_LABEL)
@@ -212,6 +213,7 @@ PG_KEYWORD("index", INDEX, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("indexes", INDEXES, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("inherit", INHERIT, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("inherits", INHERITS, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("initial", INITIAL, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("initially", INITIALLY, RESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("inline", INLINE_P, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("inner", INNER_P, TYPE_FUNC_NAME_KEYWORD, BARE_LABEL)
@@ -263,6 +265,7 @@ PG_KEYWORD("match", MATCH, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("matched", MATCHED, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("materialized", MATERIALIZED, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("maxvalue", MAXVALUE, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("measures", MEASURES, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("merge", MERGE, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("method", METHOD, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("minute", MINUTE_P, UNRESERVED_KEYWORD, AS_LABEL)
@@ -324,12 +327,15 @@ PG_KEYWORD("partial", PARTIAL, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("partition", PARTITION, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("passing", PASSING, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("password", PASSWORD, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("past", PAST, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("pattern", PATTERN, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("placing", PLACING, RESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("plans", PLANS, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("policy", POLICY, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("position", POSITION, COL_NAME_KEYWORD, BARE_LABEL)
PG_KEYWORD("preceding", PRECEDING, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("precision", PRECISION, COL_NAME_KEYWORD, AS_LABEL)
+PG_KEYWORD("premute", PREMUTE, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("prepare", PREPARE, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("prepared", PREPARED, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("preserve", PRESERVE, UNRESERVED_KEYWORD, BARE_LABEL)
@@ -383,6 +389,7 @@ PG_KEYWORD("scroll", SCROLL, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("search", SEARCH, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("second", SECOND_P, UNRESERVED_KEYWORD, AS_LABEL)
PG_KEYWORD("security", SECURITY, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("seek", SEEK, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("select", SELECT, RESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("sequence", SEQUENCE, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("sequences", SEQUENCES, UNRESERVED_KEYWORD, BARE_LABEL)
@@ -414,6 +421,7 @@ PG_KEYWORD("stored", STORED, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("strict", STRICT_P, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("strip", STRIP_P, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("subscription", SUBSCRIPTION, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("subset", SUBSET, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("substring", SUBSTRING, COL_NAME_KEYWORD, BARE_LABEL)
PG_KEYWORD("support", SUPPORT, UNRESERVED_KEYWORD, BARE_LABEL)
PG_KEYWORD("symmetric", SYMMETRIC, RESERVED_KEYWORD, BARE_LABEL)
diff --git a/src/include/parser/parse_node.h b/src/include/parser/parse_node.h
index f589112d5e..6640090910 100644
--- a/src/include/parser/parse_node.h
+++ b/src/include/parser/parse_node.h
@@ -51,6 +51,7 @@ typedef enum ParseExprKind
EXPR_KIND_WINDOW_FRAME_RANGE, /* window frame clause with RANGE */
EXPR_KIND_WINDOW_FRAME_ROWS, /* window frame clause with ROWS */
EXPR_KIND_WINDOW_FRAME_GROUPS, /* window frame clause with GROUPS */
+ EXPR_KIND_RPR_DEFINE, /* DEFINE */
EXPR_KIND_SELECT_TARGET, /* SELECT target list item */
EXPR_KIND_INSERT_TARGET, /* INSERT target list item */
EXPR_KIND_UPDATE_SOURCE, /* UPDATE assignment source item */
--
2.25.1
----Next_Part(Mon_Jun_26_17_45_07_2023_724)--
Content-Type: Text/X-Patch; charset=us-ascii
Content-Transfer-Encoding: 7bit
Content-Disposition: inline;
filename="v2-0002-Row-pattern-recognition-patch-parse-analysis.patch"
^ permalink raw reply [nested|flat] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 152+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-07-06 18:50 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 152+ messages in thread
From: Andrey Rachitskiy @ 2026-07-06 18:50 UTC (permalink / raw)
To: PostgreSQL Hackers <[email protected]>; +Cc: Nikolay Shaplov <[email protected]>; Amit Langote <[email protected]>; Andrey Borodin <[email protected]>
Hi, Hackers!
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in
executeAnyItem(). A chain of k such accessors can cost O(N^k) on a
document with N nodes, even though for existence semantics it is
equivalent to a single .** with merged level bounds.
Background
----------
We originally reported this as BUG #19362 [1], with a follow-up
analysis [2]. The original report used a fuzzer-generated sql
statement; the follow-up gave a clearer reproduction. For example, on
a JSON array nested 1000 levels deep:
SELECT data @? '$.**.**.**.**' FROM test_json; -- ~0.5 ms
SELECT data @? '$.**.**.**.**.*' FROM test_json; -- >23 min
(cancelled)
Andrey Borodin replied that this looks like a performance optimization
opportunity rather than a bug, and asked for a more realistic example of
what a user might want but not get in reasonable time [3]. We agree,
and are sending this to pgsql-hackers.
A developer searching semi-structured JSON (nested categories, comment
threads, task lists) might use @? / jsonb_path_exists with paths such as
$.**.b ? (@ > 0) — "does key b exist anywhere, with this predicate?"
Templates, copy-paste, or auto-generated paths can accidentally
accumulate redundant .** segments (.**.**.b), which is semantically the
same as $.**.b for existence checks but much slower.
If an application accepts user-supplied jsonpath for @?, a path with
many consecutive .** operators also becomes an easy DoS vector: no
special privileges beyond the ability to run a query.
Proposal
--------
Collapse consecutive jpiAny nodes at execution time for existence
queries (@? and jsonb_path_exists): merge level bounds and perform a
single executeAnyItem() pass (.** {a,b} .** {c,d} -> .** {a+c,b+d}).
For existence checks this bounds the cost of a k-deep .** chain to
O(N) instead of O(N^k), so paths that previously hung the backend for
many minutes on the BUG #19362 reproduction now finish in under a
millisecond. That closes a practical DoS vector for applications that
pass user-supplied jsonpath to @?.
This patch intentionally does NOT change jsonb_path_query or @@: those
functions collect item sequences, and consecutive .** affects result
multiplicity (e.g. lax $.**.** vs $.**). The optimization is gated on
existsOnly (result == NULL in executeJsonPath()).
jsonb_jsonpath regress tests are included.
Backpatch-through: 15.
Separate patches for REL_15_STABLE through master are attached.
[1] https://postgr.es/m/[email protected]
[2] https://postgr.es/m/[email protected]
[3]
https://postgr.es/m/[email protected]
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Nikolay Shaplov <[email protected]>
Tested-on: REL_15_STABLE .. master
--
Regards,
Andrey Rachitskiy
^ permalink raw reply [nested|flat] 152+ messages in thread
end of thread, other threads:[~2026-07-06 18:50 UTC | newest]
Thread overview: 152+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2023-06-26 08:05 [PATCH v2 1/7] Row pattern recognition patch for raw parser. 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-07-06 18:50 [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