Skip to content

Fix the aggregations and orders a mixed index answers - #5032

Open
porunov wants to merge 1 commit into
JanusGraph:masterfrom
porunov:mixed-index-aggregations
Open

porunov wants to merge 1 commit into
JanusGraph:masterfrom
porunov:mixed-index-aggregations

Conversation

@porunov

@porunov porunov commented Oct 5, 2026

Copy link
Copy Markdown
Member

Fixes #5031

When a mixed index answers the query in front of count(), min(), max(), sum() or mean(), JanusGraph computes the aggregation in the index. That answer differed from the one the traversal computes from the elements:

  • An aggregation of no values gave 0 with Elasticsearch, and 0, NaN or an exception with Lucene and Solr, where TinkerPop has no result.
  • Lucene failed on most aggregations once some matching elements lacked the key, and on the minimum and maximum of a LIST or SET key.
  • A minimum, maximum, sum or mean ignored a limit() or range() in front, and a count ignored the range's offset.
  • The index aggregated without the transaction's own changes, and over a key that isn't enabled in it yet.
  • A count stopped at query.hard-max-limit or query.smart-limit.
  • A minimum, maximum, sum or mean after a has() on a key which doesn't exist, or one that a composite index answers, failed while the traversal was optimized.

Separately, Lucene, and Solr with JanusGraph's schemas, sorted the elements without the key of an order().by(key) among the others, as 0 for numbers and dates. JanusGraph puts them last when it orders elements itself, and so does Elasticsearch.

The issue has the tables of what master gives on each backend.

Changes

  • JanusGraphMixedIndexAggStep:
    • A null answer gives no result.
    • The step only stands in for the traversal where the index gives the same answer:
      • for exactly one mixed index subquery;
      • outside a transaction with uncommitted changes, any of which sends the aggregation to memory, as the index doesn't hold them;
      • for a key which is ENABLED in the index;
      • and, except for a count, without a limit or an offset.
    • A count stops at the traversal's limit: the index query gets that limit instead of the one the query builder sized for fetching elements in batches. The offset of a range() is taken off the count.
    • The Javadoc of IndexProvider.queryAggregation and MixedIndexAggQuery.execute says what null means.
  • Lucene:
    • The sort fields of a query's order put the documents without the field after the others in either direction. They sort as the type's greatest value when ascending and its least when descending, and strings without a value sort last.
    • The minimum and maximum of a single-valued field come from the first, in that order, of the matching documents which have a value (a FieldExistsQuery filter). Without the filter, a document without a value could tie with one holding Long.MAX_VALUE and come first.
    • A LIST or SET field has no doc values to sort by, so its minimum and maximum come from StatsCollector, renamed from SumCollector, as every sum and mean does. It reads every stored value of the matching documents and counts them.
    • The mean divides by the number of values, not the number of documents.
  • Elasticsearch:
    • RestAggValue.value is a Double, so the null of a min, max or avg aggregation of no values stays null. ElasticSearchClient.avg returns Double.
    • A sum comes from the stats aggregation, whose count tells a sum of no values from a sum of 0.
    • executeAggs returns the parsed aggregation. The line that Bound Elasticsearch counts by their limit and ask aggregations for less #5013 changes is untouched, so the two merge cleanly.
  • Solr:
    • The stats requests ask for the count of values as well, and a count of 0 gives null.
    • The test and example schemas declare sortMissingLast="true" on int, long, float, double and date and their t aliases, as their string and boolean types already did.
  • Docs:
    • The changelog entry has upgrade notes: next() on an aggregation of no values now throws NoSuchElementException, and the changed signatures.
    • The Solr page has a section on where the schema puts elements without a value.

Not changed:

  • A sum keeps the number type the index gives it, a Long for whole numbers and a Double for decimals. TinkerPop keeps the type of the values unless the sum outgrows it. The tests compare numeric values.
  • The ordering test follows JanusGraph's own order of elements without the key, last. When TinkerPop's OrderGlobalStep orders, it drops them, as their by() produces nothing. JanusGraph folds an order that a mixed index can answer into its own step, which keeps them, and this PR makes Lucene and Solr put them where that step does.
  • A Lucene count beyond 1000 matches can be too low. That is a separate issue, with its own PR.

Tests

  • JanusGraphIndexTest.testMixedIndexAggregationsOfTheValuesThereAre (new, every index backend):
    • What it checks:
      • min, max, sum and mean of an Integer, a Double and a LIST key, over vertices with and without values, a match without values, and no match.
      • The minimum of a Long key holding only Long.MAX_VALUE, and the maximum of one holding only Long.MIN_VALUE, each beside a vertex without a value that was indexed first.
      • Counts after limit() and range(), and aggregations after them.
      • Aggregations in a transaction with changes, and over a key added to the index after its data.
      • A count under query.hard-max-limit = 1.
      • A has() on a key which doesn't exist, and one that a composite index answers.
    • Apart from those last two has() cases, every case is compared with the in-memory result of the same traversal and with the expected value, and the profile must show whether JanusGraphMixedIndexAggStep answered it.
    • On master: Lucene fails with NullPointerException at its first aggregation, Elasticsearch gives [0] where memory gives nothing, and Solr fails with NullPointerException.
    • Each of these, reverted alone, fails the test on Lucene: the transaction check, the ENABLED filter, the index query's limit, the count's offset, the limit and offset condition, and the existence filter.
  • JanusGraphIndexTest.testMixedIndexOrdersElementsWithoutTheKeyLast (new): Integer, Double and String keys, ascending and descending, with and without limit. On master it fails with Lucene and Solr and passes with Elasticsearch.
  • Locally, Java 11:
    • Lucene: BerkeleyLuceneTest (83), LuceneIndexTest (255, 148 skipped) and the module's other tests.
    • Elasticsearch 9.5.4: BerkeleyElasticsearchTest (94, 1 skipped), ElasticsearchIndexTest (287) and the module's unit tests.
    • Solr: the Solr container doesn't start on this machine, master included. A scratch subclass, not part of this PR, ran JanusGraphIndexTest against Solr 9.10.1 in process instead, through SolrRunner's MiniSolrCloudCluster with the test configset. Of its 86 tests, 84 pass. testClearStorage is disabled, as in the Solr test classes, and testOrderingListProperty can't create its collection, which SolrRunner has no configset for.
  • Solr 8.11.4 and 9.10.1 in throwaway containers, queried with curl. Both:
    • compute {!min=true max=true sum=true mean=true count=true} over a multi-valued int point field across all values: min -7, max 10, count 3, sum 6, mean 2.0;
    • answer no values with a null min and max, a count of 0, a sum of 0.0 and a mean of NaN;
    • sort the missing values as 0 without sortMissingLast, and last in both directions with it.
  • Every case of the issue's tables, run on master and on this branch with each backend: on the branch, all three backends print the same results as in memory.

🤖 Generated with Claude Code

@porunov
porunov requested a balanced review from Copilot October 5, 2026 17:50

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.

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.

Copilot review overview

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

This PR fixes incorrect mixed-index aggregations (count/min/max/sum/mean) and ordering behavior across Lucene/Elasticsearch/Solr so results match in-memory traversal semantics, especially for missing values, limits/ranges, and transactional changes.

Changes:

  • Make mixed-index aggregations return “no result” (null → no traverser output) when aggregating over no values, and restrict when index-side aggregation is allowed.
  • Fix Lucene/Solr ordering so documents missing the ordered field sort last in both directions; improve Lucene min/max handling with existence filtering and multi-valued fields.
  • Adjust Elasticsearch/Solr aggregation parsing to preserve nulls and distinguish “no values” from numeric zero; add new backend tests covering these cases.
File Description
janusgraph-solr/​src/​test/​resources/​solr/​core-template/​schema.xml Sets sortMissingLast="true" for numeric/date types in test schema to match JanusGraph ordering semantics.
janusgraph-solr/​src/​main/​java/​org/​janusgraph/​diskstorage/​solr/​SolrIndex.java Uses stats count to return null for min/max/sum/mean over no values instead of backend-specific defaults.
janusgraph-lucene/​src/​main/​java/​org/​janusgraph/​diskstorage/​lucene/​SumCollector.java Reworks collector into a stats-style collector tracking count/sum/min/max across stored field values.
janusgraph-lucene/​src/​main/​java/​org/​janusgraph/​diskstorage/​lucene/​LuceneIndex.java Ensures missing-field ordering sorts last, fixes min/max via existence filtering, and bases avg on value-count.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestElasticSearchClient.java Parses full aggregation result objects to preserve nulls; uses stats to detect “no values” for sum.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestAggValue.java Supports null value and adds count/sum fields for stats aggregation parsing.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchClient.java Changes avg contract to return Double (nullable) to represent “no values”.
janusgraph-dist/​src/​assembly/​static/​conf/​solr/​schema.xml Updates distributed Solr schema to sort missing numeric/date values last.
janusgraph-core/​src/​main/​java/​org/​janusgraph/​graphdb/​tinkerpop/​optimize/​step/​JanusGraphMixedIndexAggStep.java Restricts index aggregation to cases matching traversal semantics; handles null results and range offset for counts.
janusgraph-core/​src/​main/​java/​org/​janusgraph/​diskstorage/​indexing/​IndexProvider.java Documents null semantics for aggregations over no values.
janusgraph-core/​src/​main/​java/​org/​janusgraph/​core/​MixedIndexAggQuery.java Documents that null indicates no values for min/max/mean/sum.
janusgraph-backend-testutils/​src/​main/​java/​org/​janusgraph/​graphdb/​JanusGraphIndexTest.java Adds regression tests for mixed-index aggregations and ordering, including missing values and limit/range behavior.
docs/​index-backend/​solr.md Documents Solr schema requirements for ordering elements missing the ordered field.
docs/​changelog.md Adds upgrade notes explaining changed aggregation semantics and API signature updates.

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

Fixes JanusGraph#5031

* JanusGraphMixedIndexAggStep emits no result when the index answers a
  min, max, sum or mean with null, which IndexProvider.queryAggregation
  now documents as "no values", so an aggregation of no values has no
  result, as when TinkerPop aggregates the values itself.
* The step only stands in for the traversal where the index gives the
  same answer: for exactly one mixed index subquery, as a has() on a
  key which doesn't exist, or one a composite index answers, failed
  with IndexOutOfBoundsException or ClassCastException; outside a
  transaction with uncommitted changes, which the index doesn't hold,
  whichever elements they touch; for a key
  which is ENABLED in the index; and, but for a count, without a limit
  or an offset, as the index aggregates every match. A count stops at
  the limit of the traversal, where the index query's own limit may be
  capped by query.hard-max-limit or query.smart-limit, and leaves out
  the offset of a range.
* Lucene sorts documents without the field after the others in either
  direction, as JanusGraph orders elements in memory and Elasticsearch
  does, where it sorted them as 0 or, for strings ascending, first.
  The minimum and maximum of a single-valued field come from the first,
  in that order, of the matching documents which have a value, null
  when none has; those of a LIST or SET field, which has no doc values
  to sort by, and every sum and mean come from StatsCollector
  (SumCollector before), which reads every stored value of the matching
  documents and counts them. The mean divides by the number of values
  instead of the number of documents.
* Elasticsearch keeps the null value of a min, max or avg aggregation
  of no values, which became 0, and computes a sum from the stats
  aggregation, whose count tells a sum of no values from a sum of 0.
* Solr asks its stats for the count of values as well and returns null
  for a count of 0, where a minimum or maximum failed and a sum was 0
  and a mean NaN. The numeric and date types of the test and example
  schemas declare sortMissingLast="true".
* Tests: JanusGraphIndexTest.testMixedIndexAggregationsOfTheValuesThereAre
  and testMixedIndexOrdersElementsWithoutTheKeyLast, for every index
  backend. Docs: changelog entry, and the Solr page says how the
  schema decides where elements without a value go.

Co-Authored-By: Oleksandr Porunov <alexandr.porunov@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Oleksandr Porunov <alexandr.porunov@gmail.com>
@porunov
porunov force-pushed the mixed-index-aggregations branch from efc457b to b8310c5 Compare October 5, 2026 18:11
@porunov
porunov requested a balanced review from Copilot October 5, 2026 18:13

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.

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.

Copilot review overview

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (2)

This branch has not been deployed

No deployments
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.

Mixed index aggregations differ from the traversal's result, and Lucene and Solr order elements without the key among the others

2 participants