agora inbox for [email protected]
help / color / mirror / Atom feed[PATCH v2 2/4] Introduce ternary reloptions
602+ messages / 2 participants
[nested] [flat]
* [PATCH v2 2/4] Introduce ternary reloptions
@ 2025-09-04 14:41 Nikolay Shaplov <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Nikolay Shaplov @ 2025-09-04 14:41 UTC (permalink / raw)
Introduce ternary reloption as a replacement for current `vacuum_truncate`
implementation. Remove `vacuum_truncate_set` additional flag and using
`TERNARY_UNSET` value instead.
---
src/backend/access/common/reloptions.c | 129 ++++++++++++++++++++-----
src/backend/commands/vacuum.c | 4 +-
src/include/access/reloptions.h | 26 ++---
src/include/c.h | 16 +++
src/include/utils/rel.h | 3 +-
5 files changed, 135 insertions(+), 43 deletions(-)
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 0af3fea68f..f543f11001 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -40,9 +40,9 @@
*
* To add an option:
*
- * (i) decide on a type (bool, integer, real, enum, string), name, default
- * value, upper and lower bounds (if applicable); for strings, consider a
- * validation routine.
+ * (i) decide on a type (bool, ternary, integer, real, enum, string), name,
+ * default value, upper and lower bounds (if applicable); for strings,
+ * consider a validation routine.
* (ii) add a record below (or use add_<type>_reloption).
* (iii) add it to the appropriate options struct (perhaps StdRdOptions)
* (iv) add it to the appropriate handling routine (perhaps
@@ -147,15 +147,6 @@ static relopt_bool boolRelOpts[] =
},
false
},
- {
- {
- "vacuum_truncate",
- "Enables vacuum to truncate empty pages at the end of this table",
- RELOPT_KIND_HEAP | RELOPT_KIND_TOAST,
- ShareUpdateExclusiveLock
- },
- true
- },
{
{
"deduplicate_items",
@@ -170,6 +161,21 @@ static relopt_bool boolRelOpts[] =
{{NULL}}
};
+static relopt_ternary ternaryRelOpts[] =
+{
+ {
+ {
+ "vacuum_truncate",
+ "Enables vacuum to truncate empty pages at the end of this table",
+ RELOPT_KIND_HEAP | RELOPT_KIND_TOAST,
+ ShareUpdateExclusiveLock
+ },
+ TERNARY_UNSET
+ },
+ /* list terminator */
+ {{NULL}}
+};
+
static relopt_int intRelOpts[] =
{
{
@@ -600,6 +606,13 @@ initialize_reloptions(void)
boolRelOpts[i].gen.lockmode));
j++;
}
+ for (i = 0; ternaryRelOpts[i].gen.name; i++)
+ {
+ Assert(DoLockModesConflict(ternaryRelOpts[i].gen.lockmode,
+ ternaryRelOpts[i].gen.lockmode));
+ j++;
+ }
+
for (i = 0; intRelOpts[i].gen.name; i++)
{
Assert(DoLockModesConflict(intRelOpts[i].gen.lockmode,
@@ -640,6 +653,14 @@ initialize_reloptions(void)
j++;
}
+ for (i = 0; ternaryRelOpts[i].gen.name; i++)
+ {
+ relOpts[j] = &ternaryRelOpts[i].gen;
+ relOpts[j]->type = RELOPT_TYPE_TERNARY;
+ relOpts[j]->namelen = strlen(relOpts[j]->name);
+ j++;
+ }
+
for (i = 0; intRelOpts[i].gen.name; i++)
{
relOpts[j] = &intRelOpts[i].gen;
@@ -800,6 +821,9 @@ allocate_reloption(bits32 kinds, int type, const char *name, const char *desc,
case RELOPT_TYPE_BOOL:
size = sizeof(relopt_bool);
break;
+ case RELOPT_TYPE_TERNARY:
+ size = sizeof(relopt_ternary);
+ break;
case RELOPT_TYPE_INT:
size = sizeof(relopt_int);
break;
@@ -883,6 +907,54 @@ add_local_bool_reloption(local_relopts *relopts, const char *name,
add_local_reloption(relopts, (relopt_gen *) newoption, offset);
}
+/*
+ * init_ternary_reloption
+ * Allocate and initialize a new ternary reloption
+ */
+static relopt_ternary *
+init_ternary_reloption(bits32 kinds, const char *name, const char *desc,
+ ternary default_val, LOCKMODE lockmode)
+{
+ relopt_ternary *newoption;
+
+ newoption = (relopt_ternary *) allocate_reloption(kinds,
+ RELOPT_TYPE_TERNARY, name, desc, lockmode);
+ newoption->default_val = default_val;
+
+ return newoption;
+}
+
+/*
+ * add_ternary_reloption
+ * Add a new ternary reloption
+ */
+void
+add_ternary_reloption(bits32 kinds, const char *name, const char *desc,
+ ternary default_val, LOCKMODE lockmode)
+{
+ relopt_ternary *newoption = init_ternary_reloption(kinds, name, desc,
+ default_val, lockmode);
+
+ add_reloption((relopt_gen *) newoption);
+}
+
+/*
+ * add_local_ternary_reloption
+ * Add a new ternary local reloption
+ *
+ * 'offset' is offset of ternary-typed field.
+ */
+void
+add_local_ternary_reloption(local_relopts *relopts, const char *name,
+ const char *desc, ternary default_val,
+ int offset)
+{
+ relopt_ternary *newoption = init_ternary_reloption(RELOPT_KIND_LOCAL,
+ name, desc,
+ default_val, 0);
+
+ add_local_reloption(relopts, (relopt_gen *) newoption, offset);
+}
/*
* init_real_reloption
@@ -1617,6 +1689,18 @@ parse_one_reloption(relopt_value *option, char *text_str, int text_len,
option->gen->name, value)));
}
break;
+ case RELOPT_TYPE_TERNARY:
+ {
+ bool b;
+ parsed = parse_bool(value, &b);
+ option->values.ternary_val = b ? TERNARY_TRUE : TERNARY_FALSE;
+ if (validate && !parsed)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("invalid value for ternary option \"%s\": %s",
+ option->gen->name, value)));
+ }
+ break;
case RELOPT_TYPE_INT:
{
relopt_int *optint = (relopt_int *) option->gen;
@@ -1780,17 +1864,6 @@ fillRelOptions(void *rdopts, Size basesize,
char *itempos = ((char *) rdopts) + elems[j].offset;
char *string_val;
- /*
- * If isset_offset is provided, store whether the reloption is
- * set there.
- */
- if (elems[j].isset_offset > 0)
- {
- char *setpos = ((char *) rdopts) + elems[j].isset_offset;
-
- *(bool *) setpos = options[i].isset;
- }
-
switch (options[i].gen->type)
{
case RELOPT_TYPE_BOOL:
@@ -1798,6 +1871,11 @@ fillRelOptions(void *rdopts, Size basesize,
options[i].values.bool_val :
((relopt_bool *) options[i].gen)->default_val;
break;
+ case RELOPT_TYPE_TERNARY:
+ *(ternary *) itempos = options[i].isset ?
+ options[i].values.ternary_val :
+ ((relopt_ternary *) options[i].gen)->default_val;
+ break;
case RELOPT_TYPE_INT:
*(int *) itempos = options[i].isset ?
options[i].values.int_val :
@@ -1912,8 +1990,8 @@ default_reloptions(Datum reloptions, bool validate, relopt_kind kind)
offsetof(StdRdOptions, parallel_workers)},
{"vacuum_index_cleanup", RELOPT_TYPE_ENUM,
offsetof(StdRdOptions, vacuum_index_cleanup)},
- {"vacuum_truncate", RELOPT_TYPE_BOOL,
- offsetof(StdRdOptions, vacuum_truncate), offsetof(StdRdOptions, vacuum_truncate_set)},
+ {"vacuum_truncate", RELOPT_TYPE_TERNARY,
+ offsetof(StdRdOptions, vacuum_truncate)},
{"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL,
offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)}
};
@@ -1993,7 +2071,6 @@ build_local_reloptions(local_relopts *relopts, Datum options, bool validate)
elems[i].optname = opt->option->name;
elems[i].opttype = opt->option->type;
elems[i].offset = opt->offset;
- elems[i].isset_offset = 0; /* not supported for local relopts yet */
i++;
}
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 733ef40ae7..977babff54 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2221,9 +2221,9 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
{
StdRdOptions *opts = (StdRdOptions *) rel->rd_options;
- if (opts && opts->vacuum_truncate_set)
+ if (opts && opts->vacuum_truncate != TERNARY_UNSET)
{
- if (opts->vacuum_truncate)
+ if (opts->vacuum_truncate == TERNARY_TRUE)
params.truncate = VACOPTVALUE_ENABLED;
else
params.truncate = VACOPTVALUE_DISABLED;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index a604a4702c..a436697658 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -29,6 +29,7 @@
typedef enum relopt_type
{
RELOPT_TYPE_BOOL,
+ RELOPT_TYPE_TERNARY, /* on, off, unset */
RELOPT_TYPE_INT,
RELOPT_TYPE_REAL,
RELOPT_TYPE_ENUM,
@@ -80,6 +81,7 @@ typedef struct relopt_value
union
{
bool bool_val;
+ ternary ternary_val;
int int_val;
double real_val;
int enum_val;
@@ -94,6 +96,12 @@ typedef struct relopt_bool
bool default_val;
} relopt_bool;
+typedef struct relopt_rernary
+{
+ relopt_gen gen;
+ int default_val;
+} relopt_ternary;
+
typedef struct relopt_int
{
relopt_gen gen;
@@ -152,19 +160,6 @@ typedef struct
const char *optname; /* option's name */
relopt_type opttype; /* option's datatype */
int offset; /* offset of field in result struct */
-
- /*
- * isset_offset is an optional offset of a field in the result struct that
- * stores whether the option is explicitly set for the relation or if it
- * just picked up the default value. In most cases, this can be
- * accomplished by giving the reloption a special out-of-range default
- * value (e.g., some integer reloptions use -2), but this isn't always
- * possible. For example, a Boolean reloption cannot be given an
- * out-of-range default, so we need another way to discover the source of
- * its value. This offset is only used if given a value greater than
- * zero.
- */
- int isset_offset;
} relopt_parse_elt;
/* Local reloption definition */
@@ -195,6 +190,8 @@ typedef struct local_relopts
extern relopt_kind add_reloption_kind(void);
extern void add_bool_reloption(bits32 kinds, const char *name, const char *desc,
bool default_val, LOCKMODE lockmode);
+extern void add_ternary_reloption(bits32 kinds, const char *name,
+ const char *desc, int default_val, LOCKMODE lockmode);
extern void add_int_reloption(bits32 kinds, const char *name, const char *desc,
int default_val, int min_val, int max_val,
LOCKMODE lockmode);
@@ -214,6 +211,9 @@ extern void register_reloptions_validator(local_relopts *relopts,
extern void add_local_bool_reloption(local_relopts *relopts, const char *name,
const char *desc, bool default_val,
int offset);
+extern void add_local_ternary_reloption(local_relopts *relopts,
+ const char *name, const char *desc,
+ ternary default_val, int offset);
extern void add_local_int_reloption(local_relopts *relopts, const char *name,
const char *desc, int default_val,
int min_val, int max_val, int offset);
diff --git a/src/include/c.h b/src/include/c.h
index 39022f8a9d..9c59ccccf8 100644
--- a/src/include/c.h
+++ b/src/include/c.h
@@ -488,6 +488,22 @@ typedef void (*pg_funcptr_t) (void);
#include <stdbool.h>
+/*
+ * ternary
+ * Boolean value with an extrea "unset" option
+ *
+ * Ternary data type is used in relation options that can be "true", "false" or
+ * "unset". Since relation options are used deep inside the PostgreSQL code,
+ * this type is declared globally.
+*/
+
+typedef enum ternary
+{
+ TERNARY_FALSE = 0,
+ TERNARY_TRUE = 1,
+ TERNARY_UNSET = -1
+} ternary;
+
/* ----------------------------------------------------------------
* Section 3: standard system types
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index b552359915..7346d618f9 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -346,8 +346,7 @@ typedef struct StdRdOptions
bool user_catalog_table; /* use as an additional catalog relation */
int parallel_workers; /* max number of parallel workers */
StdRdOptIndexCleanup vacuum_index_cleanup; /* controls index vacuuming */
- bool vacuum_truncate; /* enables vacuum to truncate a relation */
- bool vacuum_truncate_set; /* whether vacuum_truncate is set */
+ ternary vacuum_truncate; /* enables vacuum to truncate a relation */
/*
* Fraction of pages in a relation that vacuum can eagerly scan and fail
--
2.39.2
--nextPart5129932.5fSG56mABF
Content-Disposition: attachment;
filename="v2-0003-Add-alias-to-be-used-as-unset-state.patch"
Content-Transfer-Encoding: 7Bit
Content-Type: text/x-patch; charset="unicode-2-0-utf-8";
name="v2-0003-Add-alias-to-be-used-as-unset-state.patch"
^ permalink raw reply [nested|flat] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ messages in thread
From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)
When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.
This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes. A similar pattern
in strict mode can also provoke very large memory allocation attempts.
Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.
Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
src/backend/utils/adt/jsonpath_exec.c | 47 +++++++++++++++++++---
.../src/test/regress/expected/jsonb_jsonpath.out | 40 ++++++++++++++++++
src/test/regress/sql/jsonb_jsonpath.sql | 11 +++++
3 files 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] 602+ messages in thread
* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-07-06 18:50 Andrey Rachitskiy <[email protected]>
0 siblings, 0 replies; 602+ 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] 602+ messages in thread
end of thread, other threads:[~2026-07-06 18:50 UTC | newest]
Thread overview: 602+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2025-09-04 14:41 [PATCH v2 2/4] Introduce ternary reloptions Nikolay Shaplov <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath 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