agora inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v1] bufmgr: aio: Prototype for not waiting for already-in-progress IO
657+ messages / 2 participants
[nested] [flat]

* [PATCH v1] bufmgr: aio: Prototype for not waiting for already-in-progress IO
@ 2025-08-15 15:01 Andres Freund <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andres Freund @ 2025-08-15 15:01 UTC (permalink / raw)

Author:
Reviewed-by:
Discussion: https://postgr.es/m/
Backpatch:
---
 src/include/storage/bufmgr.h        |   1 +
 src/backend/storage/buffer/bufmgr.c | 133 ++++++++++++++++++++++++++--
 2 files changed, 125 insertions(+), 9 deletions(-)

diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h
index 41fdc1e7693..7ddb867bc99 100644
--- a/src/include/storage/bufmgr.h
+++ b/src/include/storage/bufmgr.h
@@ -137,6 +137,7 @@ struct ReadBuffersOperation
 	int			flags;
 	int16		nblocks;
 	int16		nblocks_done;
+	bool		foreign_io;
 	PgAioWaitRef io_wref;
 	PgAioReturn io_return;
 };
diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index fd7e21d96d3..de755fd53ad 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -1557,6 +1557,41 @@ ReadBuffersCanStartIOOnce(Buffer buffer, bool nowait)
 		return StartBufferIO(GetBufferDescriptor(buffer - 1), true, nowait);
 }
 
+static inline bool
+ReadBuffersIOAlreadyInProgress(ReadBuffersOperation *operation, Buffer buffer)
+{
+	BufferDesc *desc;
+	uint32		buf_state;
+	PgAioWaitRef iow;
+
+	pgaio_wref_clear(&iow);
+
+	if (BufferIsLocal(buffer))
+	{
+		desc = GetLocalBufferDescriptor(-buffer - 1);
+		buf_state = pg_atomic_read_u32(&desc->state);
+		if (buf_state & BM_IO_IN_PROGRESS)
+			iow = desc->io_wref;
+	}
+	else
+	{
+		desc = GetBufferDescriptor(buffer - 1);
+		buf_state = LockBufHdr(desc);
+
+		if (buf_state & BM_IO_IN_PROGRESS)
+			iow = desc->io_wref;
+		UnlockBufHdr(desc, buf_state);
+	}
+
+	if (pgaio_wref_valid(&iow))
+	{
+		operation->io_wref = iow;
+		return true;
+	}
+
+	return false;
+}
+
 /*
  * Helper for AsyncReadBuffers that tries to get the buffer ready for IO.
  */
@@ -1689,7 +1724,7 @@ WaitReadBuffers(ReadBuffersOperation *operation)
 			 *
 			 * we first check if we already know the IO is complete.
 			 */
-			if (aio_ret->result.status == PGAIO_RS_UNKNOWN &&
+			if ((operation->foreign_io || aio_ret->result.status == PGAIO_RS_UNKNOWN) &&
 				!pgaio_wref_check_done(&operation->io_wref))
 			{
 				instr_time	io_start = pgstat_prepare_io_time(track_io_timing);
@@ -1708,11 +1743,38 @@ WaitReadBuffers(ReadBuffersOperation *operation)
 				Assert(pgaio_wref_check_done(&operation->io_wref));
 			}
 
-			/*
-			 * We now are sure the IO completed. Check the results. This
-			 * includes reporting on errors if there were any.
-			 */
-			ProcessReadBuffersResult(operation);
+			if (operation->foreign_io)
+			{
+				Buffer		buffer = operation->buffers[operation->nblocks_done];
+				BufferDesc *desc;
+				uint32		buf_state;
+
+				if (BufferIsLocal(buffer))
+				{
+					desc = GetLocalBufferDescriptor(-buffer - 1);
+					buf_state = pg_atomic_read_u32(&desc->state);
+				}
+				else
+				{
+					desc = GetBufferDescriptor(buffer - 1);
+					buf_state = LockBufHdr(desc);
+					UnlockBufHdr(desc, buf_state);
+				}
+
+				if (buf_state & BM_VALID)
+				{
+					operation->nblocks_done += 1;
+					Assert(operation->nblocks_done <= operation->nblocks);
+				}
+			}
+			else
+			{
+				/*
+				 * We now are sure the IO completed. Check the results. This
+				 * includes reporting on errors if there were any.
+				 */
+				ProcessReadBuffersResult(operation);
+			}
 		}
 
 		/*
@@ -1798,6 +1860,56 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 		io_object = IOOBJECT_RELATION;
 	}
 
+	/*
+	 * If AIO is in progress, be it in this backend or another backend, we
+	 * just associate the wait reference with the operation and wait in
+	 * WaitReadBuffers(). This turns out to be important for performance in
+	 * two workloads:
+	 *
+	 * 1) A read stream that has to read the same block multiple times within
+	 *	  the readahead distance. This can happen e.g. for the table accesses
+	 *	  of an index scan.
+	 *
+	 * 2) Concurrent scans by multiple backends on the same relation.
+	 *
+	 * If we were to synchronously wait for the in-progress IO, we'd not be
+	 * able to keep enough I/O in flight.
+	 *
+	 *
+	 * If we do find there is ongoing I/O for the buffer, we set up a 1-block
+	 * ReadBuffersOperation that WaitReadBuffers then can wait on.
+	 */
+	if (1 && ReadBuffersIOAlreadyInProgress(operation, buffers[nblocks_done]))
+	{
+		/* FIXME: probably need to wait if io_method == sync? */
+
+		*nblocks_progress = 1;
+		did_start_io = true;
+		operation->foreign_io = true;
+
+		if (0)
+			elog(LOG, "using foreign IO path");
+
+		/* FIXME: trace point */
+
+		/*
+		 * FIXME: how should this be accounted for in stats? Account as a hit
+		 * for now, quite likely *we* started this IO.
+		 */
+		if (persistence == RELPERSISTENCE_TEMP)
+			pgBufferUsage.local_blks_hit += 1;
+		else
+			pgBufferUsage.shared_blks_hit += 1;
+
+		if (operation->rel)
+			pgstat_count_buffer_hit(operation->rel);
+		pgstat_count_io_op(io_object, io_context, IOOP_HIT, 1, 0);
+		if (VacuumCostActive)
+			VacuumCostBalance += VacuumCostPageHit;
+
+		return true;
+	}
+
 	/*
 	 * If zero_damaged_pages is enabled, add the READ_BUFFERS_ZERO_ON_ERROR
 	 * flag. The reason for that is that, hopefully, zero_damaged_pages isn't
@@ -1855,9 +1967,9 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 	/*
 	 * Check if we can start IO on the first to-be-read buffer.
 	 *
-	 * If an I/O is already in progress in another backend, we want to wait
-	 * for the outcome: either done, or something went wrong and we will
-	 * retry.
+	 * If a synchronous I/O in progress in another backend (it can't be this
+	 * backend), we want to wait for the outcome: either done, or something
+	 * went wrong and we will retry.
 	 */
 	if (!ReadBuffersCanStartIO(buffers[nblocks_done], false))
 	{
@@ -1970,6 +2082,7 @@ AsyncReadBuffers(ReadBuffersOperation *operation, int *nblocks_progress)
 		if (VacuumCostActive)
 			VacuumCostBalance += VacuumCostPageMiss * io_buffers_len;
 
+		operation->foreign_io = false;
 		*nblocks_progress = io_buffers_len;
 		did_start_io = true;
 	}
@@ -5986,6 +6099,8 @@ WaitIO(BufferDesc *buf)
 		 */
 		if (pgaio_wref_valid(&iow))
 		{
+			if (0)
+				elog(LOG, "foreign wait");
 			pgaio_wref_wait(&iow);
 
 			/*
-- 
2.48.1.76.g4e746b1a31.dirty


--j4imdtjxqt5tb6mo--





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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index eedc5c8..c4d5cb1 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -282,6 +284,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -701,6 +704,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -981,16 +985,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1003,8 +1028,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1923,6 +1948,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 4bcd4e9..03fbd97 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 3e8929a..db2a21d 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc--






^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 9b7187e..21d21ea 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -115,6 +115,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -284,6 +286,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -703,6 +706,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -983,16 +987,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1005,8 +1030,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1924,6 +1949,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 8cf6ecf..0bbad25 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index acb508c..1d867d8 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg18.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index 6cc2acb..7dfc311 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -113,6 +113,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -310,6 +312,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -739,6 +742,7 @@ executeJsonPath(JsonPath *path, void *vars, JsonPathGetVarCallback getVar,
 	cxt.lastGeneratedObjectId = 1 + countVars(vars);
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenceOfErrors(&cxt) && !result)
@@ -1017,16 +1021,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -1039,8 +1064,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1978,6 +2003,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index 81efebc..21f1362 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -785,6 +785,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index c1f4ab5..92abe13 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -150,6 +150,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg15.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index a758e1a..d08b122 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -108,6 +108,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -200,6 +202,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -568,6 +571,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -921,16 +925,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -943,8 +968,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1374,6 +1399,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg17.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-06-18 20:30 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ messages in thread

From: Andrey Rachitskiy @ 2026-06-18 20:30 UTC (permalink / raw)

When a jsonpath expression contains multiple consecutive .** (jpiAny)
accessors, each one triggers a full subtree traversal in executeAnyItem().
k consecutive .** operators can degrade performance to O(N^k) on a document
with N nodes, even though a chain like $.**.**.** is redundant for existence
semantics and equivalent to a single $.** with merged level bounds.

This was reported as a performance problem when expressions such as
$.**.**.**.**.* are evaluated with @? against deeply nested JSON: the same
query without the trailing .* completes in sub-millisecond time, while the
form with redundant .** segments can run for many minutes.  A similar pattern
in strict mode can also provoke very large memory allocation attempts.

Collapse consecutive jpiAny nodes at execution time for existence queries
(@? and jsonb_path_exists), merging their level bounds and performing a
single executeAnyItem() pass.

Author: Andrey Rachitskiy <[email protected]>
Reported-by: Andrey Rachitskiy <[email protected]>
Backpatch-through: 15
---
 src/backend/utils/adt/jsonpath_exec.c     | 47 +++++++++++++++++++---
 .../src/test/regress/expected/jsonb_jsonpath.out   | 40 ++++++++++++++++++
 src/test/regress/sql/jsonb_jsonpath.sql   | 11 +++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/src/backend/utils/adt/jsonpath_exec.c b/src/backend/utils/adt/jsonpath_exec.c
index ba9bbb6..74fc499 100644
--- a/src/backend/utils/adt/jsonpath_exec.c
+++ b/src/backend/utils/adt/jsonpath_exec.c
@@ -107,6 +107,8 @@ typedef struct JsonPathExecContext
 										 * ignored */
 	bool		throwErrors;	/* with "false" all suppressible errors are
 								 * suppressed */
+	bool		existsOnly;		/* @? / jsonb_path_exists: may collapse
+							 * redundant consecutive .** accessors */
 	bool		useTz;
 } JsonPathExecContext;
 
@@ -199,6 +201,7 @@ static JsonPathExecResult executeAnyItem(JsonPathExecContext *cxt,
 										 JsonPathItem *jsp, JsonbContainer *jbc, JsonValueList *found,
 										 uint32 level, uint32 first, uint32 last,
 										 bool ignoreStructuralErrors, bool unwrapNext);
+static uint32 mergeAnyBound(uint32 a, uint32 b);
 static JsonPathBool executePredicate(JsonPathExecContext *cxt,
 									 JsonPathItem *pred, JsonPathItem *larg, JsonPathItem *rarg,
 									 JsonbValue *jb, bool unwrapRightArg,
@@ -567,6 +570,7 @@ executeJsonPath(JsonPath *path, Jsonb *vars, Jsonb *json, bool throwErrors,
 	cxt.lastGeneratedObjectId = vars ? 2 : 1;
 	cxt.innermostArraySize = -1;
 	cxt.throwErrors = throwErrors;
+	cxt.existsOnly = (result == NULL);
 	cxt.useTz = useTz;
 
 	if (jspStrictAbsenseOfErrors(&cxt) && !result)
@@ -920,16 +924,37 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 
 		case jpiAny:
 			{
-				bool		hasNext = jspGetNext(jsp, &elem);
+				JsonPathItem elem;
+				bool		hasNext;
+				uint32		first;
+				uint32		last;
+
+				hasNext = jspGetNext(jsp, &elem);
+				first = jsp->content.anybounds.first;
+				last = jsp->content.anybounds.last;
+
+				/*
+				 * Consecutive .** accessors are redundant for existence
+				 * queries and multiply traversal cost.
+				 */
+				if (cxt->existsOnly)
+				{
+					while (hasNext && elem.type == jpiAny)
+					{
+						first = mergeAnyBound(first, elem.content.anybounds.first);
+						last = mergeAnyBound(last, elem.content.anybounds.last);
+						hasNext = jspGetNext(&elem, &elem);
+					}
+				}
 
 				/* first try without any intermediate steps */
-				if (jsp->content.anybounds.first == 0)
+				if (first == 0)
 				{
 					bool		savedIgnoreStructuralErrors;
 
 					savedIgnoreStructuralErrors = cxt->ignoreStructuralErrors;
 					cxt->ignoreStructuralErrors = true;
-					res = executeNextItem(cxt, jsp, &elem,
+					res = executeNextItem(cxt, jsp, hasNext ? &elem : NULL,
 										  jb, found, true);
 					cxt->ignoreStructuralErrors = savedIgnoreStructuralErrors;
 
@@ -942,8 +967,8 @@ executeItemOptUnwrapTarget(JsonPathExecContext *cxt, JsonPathItem *jsp,
 						(cxt, hasNext ? &elem : NULL,
 						 jb->val.binary.data, found,
 						 1,
-						 jsp->content.anybounds.first,
-						 jsp->content.anybounds.last,
+						 first,
+						 last,
 						 true, jspAutoUnwrap(cxt));
 				break;
 			}
@@ -1373,6 +1398,18 @@ executeNestedBoolItem(JsonPathExecContext *cxt, JsonPathItem *jsp,
 	return res;
 }
 
+
+/*
+ * Merge level bounds of two consecutive .** accessors.
+ */
+static uint32
+mergeAnyBound(uint32 a, uint32 b)
+{
+	if (a == PG_UINT32_MAX || b == PG_UINT32_MAX)
+		return PG_UINT32_MAX;
+	return a + b;
+}
+
 /*
  * Implementation of several jsonpath nodes:
  *  - jpiAny (.** accessor),
diff --git a/src/test/regress/expected/jsonb_jsonpath.out b/src/test/regress/expected/jsonb_jsonpath.out
index eafb421..5e1bd19 100644
--- a/src/test/regress/expected/jsonb_jsonpath.out
+++ b/src/test/regress/expected/jsonb_jsonpath.out
@@ -792,6 +792,46 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
  t
 (1 row)
 
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+ ?column? 
+----------
+ t
+(1 row)
+
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+ ?column? 
+----------
+ f
+(1 row)
+
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+ jsonb_path_exists 
+-------------------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+ ?column? 
+----------
+ t
+(1 row)
+
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
  jsonb_path_query 
 ------------------
diff --git a/src/test/regress/sql/jsonb_jsonpath.sql b/src/test/regress/sql/jsonb_jsonpath.sql
index 8163fc6..4c62fe2 100644
--- a/src/test/regress/sql/jsonb_jsonpath.sql
+++ b/src/test/regress/sql/jsonb_jsonpath.sql
@@ -155,6 +155,17 @@ select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{0 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to last}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{1 to 2}.b ? ( @ > 0)';
 select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**{2 to 3}.b ? ( @ > 0)';
+-- Redundant consecutive .** accessors are collapsed for existence queries
+-- (@? and jsonb_path_exists), avoiding multiplicative traversal cost.
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 0)';
+select jsonb '{"a": {"c": {"b": 1}}}' @? '$.**.**.b ? (@ > 99)';
+select jsonb_path_exists('{"a": {"c": {"b": 1}}}', '$.**.**.b ? (@ > 0)');
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'lax $.**.**.**.**';
+select ('[' || repeat('[', 50) || '0' || repeat(']', 50) || ']')::jsonb
+	@? 'strict $.**.**.**';
+
 
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.x))');
 select jsonb_path_query('{"g": {"x": 2}}', '$.g ? (exists (@.y))');
-- 
2.47.3

--MP_/BlgiDE/t55y8Vb7V2uvNybc
Content-Type: text/x-patch
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename=0001-collapse-consecutive-jsonpath-any-pg16.patch



^ permalink  raw  reply  [nested|flat] 657+ messages in thread

* [PATCH] Collapse consecutive .** accessors for jsonpath exists queries
@ 2026-07-06 18:50 Andrey Rachitskiy <[email protected]>
  0 siblings, 0 replies; 657+ 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] 657+ messages in thread


end of thread, other threads:[~2026-07-06 18:50 UTC | newest]

Thread overview: 657+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2025-08-15 15:01 [PATCH v1] bufmgr: aio: Prototype for not waiting for already-in-progress IO Andres Freund <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath exists queries Andrey Rachitskiy <[email protected]>
2026-06-18 20:30 [PATCH] Collapse consecutive .** accessors for jsonpath 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