agora inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v29 03/11] Allow to prolong life span of transition tables until transaction end
192+ messages / 2 participants
[nested] [flat]

* [PATCH v29 03/11] Allow to prolong life span of transition tables until transaction end
@ 2019-12-20 01:09 Yugo Nagata <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Yugo Nagata @ 2019-12-20 01:09 UTC (permalink / raw)

Originally, tuplestores of AFTER trigger's transition tables were
freed for each query depth. For our IVM implementation, we would like
to prolong life of the tuplestores because we have to preserve them
for a whole query assuming that some base tables might be changed
in some trigger functions.
---
 src/backend/commands/trigger.c | 83 ++++++++++++++++++++++++++++++++--
 src/include/commands/trigger.h |  2 +
 2 files changed, 81 insertions(+), 4 deletions(-)

diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index 52177759ab..00b20f4b5b 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -3742,6 +3742,10 @@ typedef struct AfterTriggerEventList
  * end of the list, so it is relatively easy to discard them.  The event
  * list chunks themselves are stored in event_cxt.
  *
+ * prolonged_tuplestored is a list of transition table tuplestores whose
+ * life are prolonged to the end of the outmost query instead of each nested
+ * query.
+ *
  * query_depth is the current depth of nested AfterTriggerBeginQuery calls
  * (-1 when the stack is empty).
  *
@@ -3807,6 +3811,7 @@ typedef struct AfterTriggersData
 	SetConstraintState state;	/* the active S C state */
 	AfterTriggerEventList events;	/* deferred-event list */
 	MemoryContext event_cxt;	/* memory context for events, if any */
+	List   *prolonged_tuplestores;	/* list of prolonged tuplestores */
 
 	/* per-query-level data: */
 	AfterTriggersQueryData *query_stack;	/* array of structs shown below */
@@ -3842,6 +3847,7 @@ struct AfterTriggersTableData
 	bool		closed;			/* true when no longer OK to add tuples */
 	bool		before_trig_done;	/* did we already queue BS triggers? */
 	bool		after_trig_done;	/* did we already queue AS triggers? */
+	bool		prolonged;			/* are transition tables prolonged? */
 	AfterTriggerEventList after_trig_events;	/* if so, saved list pointer */
 
 	/*
@@ -3891,6 +3897,7 @@ static void TransitionTableAddTuple(EState *estate,
 									TupleTableSlot *original_insert_tuple,
 									Tuplestorestate *tuplestore);
 static void AfterTriggerFreeQuery(AfterTriggersQueryData *qs);
+static void release_or_prolong_tuplestore(Tuplestorestate *ts, bool prolonged);
 static SetConstraintState SetConstraintStateCreate(int numalloc);
 static SetConstraintState SetConstraintStateCopy(SetConstraintState origstate);
 static SetConstraintState SetConstraintStateAddItem(SetConstraintState state,
@@ -4768,6 +4775,45 @@ afterTriggerInvokeEvents(AfterTriggerEventList *events,
 }
 
 
+/*
+ * SetTransitionTablePreserved
+ *
+ * Prolong lifespan of transition tables corresponding specified relid and
+ * command type to the end of the outmost query instead of each nested query.
+ * This enables to use nested AFTER trigger's transition tables from outer
+ * query's triggers.  Currently, only immediate incremental view maintenance
+ * uses this.
+ */
+void
+SetTransitionTablePreserved(Oid relid, CmdType cmdType)
+{
+	AfterTriggersTableData *table;
+	AfterTriggersQueryData *qs;
+	bool		found = false;
+	ListCell   *lc;
+
+	/* Check state, like AfterTriggerSaveEvent. */
+	if (afterTriggers.query_depth < 0)
+		elog(ERROR, "SetTransitionTablePreserved() called outside of query");
+
+	qs = &afterTriggers.query_stack[afterTriggers.query_depth];
+
+	foreach(lc, qs->tables)
+	{
+		table = (AfterTriggersTableData *) lfirst(lc);
+		if (table->relid == relid && table->cmdType == cmdType &&
+			table->closed)
+		{
+			table->prolonged = true;
+			found = true;
+		}
+	}
+
+	if (!found)
+		elog(ERROR,"could not find table with OID %d and command type %d", relid, cmdType);
+}
+
+
 /*
  * GetAfterTriggersTableData
  *
@@ -4978,6 +5024,7 @@ AfterTriggerBeginXact(void)
 	 */
 	afterTriggers.firing_counter = (CommandId) 1;	/* mustn't be 0 */
 	afterTriggers.query_depth = -1;
+	afterTriggers.prolonged_tuplestores = NIL;
 
 	/*
 	 * Verify that there is no leftover state remaining.  If these assertions
@@ -5138,19 +5185,19 @@ AfterTriggerFreeQuery(AfterTriggersQueryData *qs)
 		ts = table->old_upd_tuplestore;
 		table->old_upd_tuplestore = NULL;
 		if (ts)
-			tuplestore_end(ts);
+			release_or_prolong_tuplestore(ts, table->prolonged);
 		ts = table->new_upd_tuplestore;
 		table->new_upd_tuplestore = NULL;
 		if (ts)
-			tuplestore_end(ts);
+			release_or_prolong_tuplestore(ts, table->prolonged);
 		ts = table->old_del_tuplestore;
 		table->old_del_tuplestore = NULL;
 		if (ts)
-			tuplestore_end(ts);
+			release_or_prolong_tuplestore(ts, table->prolonged);
 		ts = table->new_ins_tuplestore;
 		table->new_ins_tuplestore = NULL;
 		if (ts)
-			tuplestore_end(ts);
+			release_or_prolong_tuplestore(ts, table->prolonged);
 		if (table->storeslot)
 		{
 			TupleTableSlot *slot = table->storeslot;
@@ -5167,6 +5214,34 @@ AfterTriggerFreeQuery(AfterTriggersQueryData *qs)
 	 */
 	qs->tables = NIL;
 	list_free_deep(tables);
+
+	/* Release prolonged tuplestores at the end of the outmost query */
+	if (afterTriggers.query_depth == 0)
+	{
+		foreach(lc, afterTriggers.prolonged_tuplestores)
+		{
+			ts = (Tuplestorestate *) lfirst(lc);
+			if (ts)
+				tuplestore_end(ts);
+		}
+		afterTriggers.prolonged_tuplestores = NIL;
+	}
+}
+
+/*
+ * Release the tuplestore, or append it to the prolonged tuplestores list.
+ */
+static void
+release_or_prolong_tuplestore(Tuplestorestate *ts, bool prolonged)
+{
+	if (prolonged && afterTriggers.query_depth > 0)
+	{
+		MemoryContext oldcxt = MemoryContextSwitchTo(CurTransactionContext);
+		afterTriggers.prolonged_tuplestores = lappend(afterTriggers.prolonged_tuplestores, ts);
+		MemoryContextSwitchTo(oldcxt);
+	}
+	else
+		tuplestore_end(ts);
 }
 
 
diff --git a/src/include/commands/trigger.h b/src/include/commands/trigger.h
index 430e3ca7dd..48a21c4c51 100644
--- a/src/include/commands/trigger.h
+++ b/src/include/commands/trigger.h
@@ -265,6 +265,8 @@ extern void AfterTriggerEndSubXact(bool isCommit);
 extern void AfterTriggerSetState(ConstraintsSetStmt *stmt);
 extern bool AfterTriggerPendingOnRel(Oid relid);
 
+extern void SetTransitionTablePreserved(Oid relid, CmdType cmdType);
+
 
 /*
  * in utils/adt/ri_triggers.c
-- 
2.25.1


--Multipart=_Mon__28_Aug_2023_16_05_30_+0900_b1OvQD_3A3ZMTGvj
Content-Type: text/x-diff;
 name="v29-0004-Add-Incremental-View-Maintenance-support-to-pg_d.patch"
Content-Disposition: attachment;
 filename="v29-0004-Add-Incremental-View-Maintenance-support-to-pg_d.patch"
Content-Transfer-Encoding: 7bit



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 192+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-07-06 18:50 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 192+ 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] 192+ messages in thread


end of thread, other threads:[~2026-07-06 18:50 UTC | newest]

Thread overview: 192+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2019-12-20 01:09 [PATCH v29 03/11] Allow to prolong life span of transition tables until transaction end Yugo Nagata <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath 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