Skip to content

Cache a subquery result set only when it is complete (#4931) - #4941

Open
batrived wants to merge 2 commits into
JanusGraph:masterfrom
batrived:fix/4931-subquery-cache-truncated
Open

Cache a subquery result set only when it is complete (#4931)#4941
batrived wants to merge 2 commits into
JanusGraph:masterfrom
batrived:fix/4931-subquery-cache-truncated

Conversation

@batrived

Copy link
Copy Markdown
Contributor

Fixes #4931.

SubqueryIterator.close cached its collected ids whenever the element iterator had no further elements. That is true both when the index ran out of results and when .limit(limit) short-circuited the stream, so a truncated prefix of the subquery results was stored as if it were the whole answer.

That prefix is then served under a key which does not distinguish it. JointIndexQuery.updateLimit propagates a new limit to its subqueries only when it holds exactly one, so for a joint query the first subquery keeps the NO_LIMIT it was constructed with, and IndexQuery.equals/hashCode therefore produce the same key for two graph queries differing only in their limit. The result is the reproduction from the issue: limit(10) then limit(1000) in the same transaction returns roughly 10.

Change

This is the first of the three options in the issue, which you noted is the least invasive. The stream is the only thing that can short-circuit, so the number of elements the limit let through tells us what happened: fewer than the limit means the limit never stopped anything and the index was read to the end.

I did not touch updateLimit. Propagating the limit to subquery 0 would make the key correct, but it would also change what the first index is asked for, which affects the intersection — StandardJanusGraphTx deliberately passes NO_LIMIT to processIntersectingRetrievals to keep intersections complete, so limiting subquery 0 needs its own reasoning.

One deliberate trade-off

A limit exactly equal to the number of results is also not cached. At that moment the index running out and the limit being reached are indistinguishable without pulling one more element. Declining to cache costs a repeated index call on a later identical query; caching a set that may be short costs silently missing results. I chose the former, and shouldNotCacheWhenTheLimitIsExactlyTheNumberOfResults pins that choice so it is visible rather than accidental.

If you would rather keep the caching in that case, the alternative is to probe the source iterator for one more element before deciding, which means holding it separately from the mapped stream.

Testing

SubqueryIteratorCacheTest covers the truncated case, the exhausted case, the no-limit case, the exactly-at-limit case, and an empty result set, which is a complete answer and should still be cached. I confirmed the two "should not cache" tests fail against master without the change and pass with it.

Note on overlap

This touches the same stream chain in SubqueryIterator as my PR for #4932, so whichever merges second will need a trivial rebase. They are independent changes: #4932 changes the intersection filter, this one adds the emitted-count and the caching condition.


For all changes:

  • Is there an issue associated with this PR? Is it referenced in the commit message?
  • Does your PR body contain #xyz where xyz is the issue number you are trying to resolve?
  • Has your PR been rebased against the latest commit within the target branch (typically master)?
  • Is your initial contribution a single, squashed commit?

For code changes:

  • Have you written and/or updated unit tests to verify your changes?
  • If adding new dependencies to the code, are these dependencies licensed in a way that is compatible for inclusion under ASF 2.0? — no new dependencies
  • If applicable, have you updated the LICENSE.txt file, including the main LICENSE.txt file in the root of this repository? — not applicable
  • If applicable, have you updated the NOTICE.txt file, including the main NOTICE.txt file found in the root of this repository? — not applicable

SubqueryIterator cached its collected ids on close whenever the element
iterator had no further elements. That is true both when the index ran out of
results and when the limit stopped the stream, so a truncated prefix of the
subquery results was stored as if it were the whole answer.

The limit is not part of the cache key of such a subquery. JointIndexQuery
propagates a new limit to its subqueries only when it holds a single one, so for
a joint query the first subquery keeps the NO_LIMIT it was built with, and two
graph queries which differ only in their limit produce the same key. A later
query in the same transaction asking for more results was therefore served the
shorter list.

Count the elements the limit let through, and cache only when fewer than the
limit were emitted, which means the limit never stopped anything.

A limit equal to the number of results is not cached either, because the index
running out and the limit being reached cannot be told apart at that point.
That costs a repeated index call, where caching a possibly short result set
costs missing results.

Signed-off-by: Balmukund Trivedi <btrivedipublic@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mad
mad enabled auto-merge August 12, 2026 04:52
@mad
mad disabled auto-merge August 12, 2026 04:52
…ead limit

The previous commit declined to cache any result set which the read limit
truncated. That is wider than necessary. SubqueryCache stores a result list
against the limit of the subquery which produced it, and serves that list only
to a later query whose limit is no larger, so a truncated list is safe to store
while the subquery carries the limit which truncated it.

JointIndexQuery.updateLimit propagates the joint limit into the subquery only
when there is a single subquery, so that condition holds there. It does not hold
once a joint query has more than one subquery: the cache then records the wider
subquery limit for a list read under the narrower joint limit, and serves too
few results to a later query whose limit falls between the two. That is the case
worth declining.

Signed-off-by: Balmukund Trivedi <btrivedipublic@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@batrived
batrived force-pushed the fix/4931-subquery-cache-truncated branch from 4aba6b4 to d58379e Compare August 12, 2026 17:04
@porunov
porunov requested a lite review from Copilot August 14, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Fixes incorrect subquery caching when a joint query’s stream is short-circuited by limit(), preventing a truncated prefix from being cached under a key that can later be reused for a larger limit.

Changes:

  • Track how many elements are actually emitted by the limited stream and gate caching on whether the cached list is “safe” for future queries.
  • Add unit tests covering truncated vs exhausted reads, no-limit behavior, and empty result set caching.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
janusgraph-core/src/main/java/org/janusgraph/graphdb/util/SubqueryIterator.java Adds emitted-count tracking and a safety predicate to avoid caching truncated prefixes under wider subquery limits.
janusgraph-core/src/test/java/org/janusgraph/graphdb/util/SubqueryIteratorCacheTest.java Introduces tests to validate caching behavior across limit/exhaustion scenarios and empty results.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +104 to +110
@Test
public void shouldCacheAResultSetWhoseIndexRanOutAsTheLimitWasReached() {
//The index ran out at the same moment the limit was reached. Which of the two stopped the read is unknown, so
//the list counts as truncated, and the subquery carrying the same limit is what makes it safe to store
runQuery(ALL_MATCHING_IDS.size(), ALL_MATCHING_IDS.size());
assertEquals(ALL_MATCHING_IDS, captureCachedResult());
}

@Test
public void shouldCacheAnEmptyResultSet() {
when(subQuery.getProfiler()).thenReturn(QueryProfiler.NO_OP);
Comment on lines +70 to +71
private List<Object> captureCachedResult() {
final ArgumentCaptor<List<Object>> cached = ArgumentCaptor.forClass(List.class);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Joint-index subquery cache stores limit-truncated results as complete, causing silently missing results

2 participants