Skip to content

Fail Elasticsearch reads which only some shards answered - #5096

Open
porunov wants to merge 1 commit into
JanusGraph:masterfrom
porunov:es-fail-on-failed-shards
Open

porunov wants to merge 1 commit into
JanusGraph:masterfrom
porunov:es-fail-on-failed-shards

Conversation

@porunov

@porunov porunov commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Fixes #5095

Elasticsearch answers a search, a page of a scroll or of a point in time, a count or an aggregation which some shards fail with the results of the shards which answered, with status 200, and fails it only when every shard failed. A shard fails, for example, where its search context expired on its node but not on the others, where it isn't available, or where it rejects the request. A shard which runs out of a search's time answers with the hits it found until then. JanusGraph read _shards.total but never _shards.failed, so it took such an answer as complete. ElasticSearchScroll and ElasticSearchPointInTime took a page with fewer hits than its size for the last one, which ended the result early, and a count or an aggregation left the failed shards out, all without an error. Such a read now fails, with the shard failures in its error, unless the new option index.[X].elasticsearch.allow-partial-results allows partial results.

Changes

  • RestElasticSearchClient:
    • Fails every answer of a search, a page of a scroll or of a point in time, a count or an aggregation, with an IOException, where:
      • shards failed (failed above 0);
      • shards counted neither as successful nor as failed (successful below total), which is how OpenSearch 2.19.6 and 3.9.0 report a shard which isn't available, without listing a failure for it. Elasticsearch 7.17.8 and 9.5.4 count such a shard as failed. Shards which a search skips because they can't match count as successful;
      • shards ran out of time (timed_out), where Elasticsearch wasn't told not to allow partial results.
    • The message names each failure listed in _shards.failures: its index and shard where the answer tells them, its type and reason, and the type and reason of its cause. For example: Elasticsearch answered a page of a scroll with the results of only 1 of 2 shards: search_context_missing_exception: No search context found for id [26]. Set index.[X].elasticsearch.allow-partial-results to true to use such results.
    • Sends allow_partial_search_results=false with every request to the search API: a search, the first page of a scroll, a page of a point in time, a count and an aggregation. It goes to Elasticsearch 7 and later and to OpenSearch, whose API is that of 7, and they then fail such a request themselves. Releases of Elasticsearch 6 before 6.3 reject the parameter, and the major version doesn't tell them apart, so Elasticsearch 6 only gets its answers checked.
    • Counts through the search API: a count is now a search for no hits which counts them all (size 0, track_total_hits true, terminate_after where the count is bounded), which is what the count API runs. The count API rejects the parameter, and its answer doesn't tell that a shard ran out of time.
    • The later pages of a scroll (/_search/scroll) don't take the parameter, so only the check covers them.
    • The scroll parameter of the first page of a scroll is now added to the request rather than written into its path; the request is the same.
  • TransientFailures.isSearchTimeout: JanusGraph doesn't reattempt a search which ran out of time, whatever its status, in RestElasticSearchClient or through BackendOperation.
    • Elasticsearch 9.5.4 fails it with search_timeout_exception and status 429, and 8.15.3 with 504, both in the default retry-error-codes. Elasticsearch 7.17.8 and 6.6.0 and OpenSearch 2.19.6 and 3.9.0 fail it with query_phase_execution_exception "Time exceeded" and 500.
    • Reattempting it would run it as long again, on a cluster which was too slow for it already: four times by the client with the defaults, then again by BackendOperation for up to storage.read-time.
    • The low-level RestClient still sends a request answered with 502, 503 or 504 to its next host, where several are configured, and takes the host for dead for a while (isRetryStatus). So a search which ran out of time on Elasticsearch 8.15.3 runs once on each host which the client doesn't take for dead. It decides that from the status before JanusGraph can read the body. Its only lever for a single request is the ignore parameter, which would also stop it from moving on from a gateway's 504 for a dead node, so this is left as it is.
  • RestShardStatistics (new): the _shards section of an answer, and the description of its failures. RestSearchResponse parses _shards into it, where it read only total into a map. RestCountResponse, which now reads the answer of a search, and RestAggResponse parse it too, and all three read timed_out.
  • ElasticSearchScroll releases its scroll context when a page fails, with an I/O error or at runtime, as ElasticSearchPointInTime already closes its point in time. Before, a page which only some shards answered came back short, which ended the scroll and released its context.
  • SubqueryIterator (janusgraph-core) remembers that reading the index failed. When it is closed afterwards, as TinkerPop's fill(), iterate() and forEachRemaining() close the traversal in a finally, it neither asks the index whether the result ended nor caches the ids it read.
    • The question could fail again: close() then threw that failure, which CloseableIterator.closeIterator wraps in RuntimeException, in place of the read's. On master, that happened after a failed page of a scroll, which hasNext() requested again. The closes after it in the chain, such as the second index's in ResultMergeSortIterator, MultiIterator or MultiDistinctOrderedIterator, were skipped, and their scroll or point in time stayed open until its keep-alive.
    • The answer could be that the result ended, as ElasticSearchPointInTime answered on master after a failed page. The transaction's cache of index results then kept the ids read before the failure as the whole result of the same query for the rest of the transaction.
  • ElasticSearchScroll and ElasticSearchPointInTime rethrow the failure of a page on every later call, rather than answer that the result ended, for any other consumer of their streams.
  • New option index.[X].elasticsearch.allow-partial-results (Boolean, default false, MASKABLE). Set to true, the client neither sends the parameter nor checks the answers, as before.
  • Option descriptions:
    • limited-result-max-page-size said that a page which only some shards answered "ends the result early or leaves hits out without an error". It now says that the next page fails, unless allow-partial-results lets it come back with the hits of the shards which kept their contexts.
    • retry-error-codes said "Queries get the client level attempts only", which a 1.2.0 change made untrue. It now says that a query, count or aggregation is reattempted for up to storage.read-time too, but not the later pages of a result read in pages, and never a search which ran out of its time.
  • Docs:
    • The Elasticsearch page, where it describes paging and the keep-alive: the same correction, and a paragraph on shards which fail or run out of time, and on the new option.
    • The configuration reference.
    • A changelog section after "Elasticsearch reads a limited result beyond 10,000 hits in larger pages", whose sentence on lost contexts says that the next page fails.

Why both the parameter and the check

Measured on single-node clusters of Elasticsearch 7.17.8 and 9.5.4 and OpenSearch 2.19.6 and 3.9.0. Each request read two shards, one of which failed through:

  • a script field which throws for one document of an index of two shards;
  • the deletion, after the first page, of one of the two indexes of one shard which a scroll or a point in time read;
  • a primary of an index of two shards which couldn't be assigned (index.routing.allocation.total_shards_per_node 1).
Request Without the parameter With allow_partial_search_results=false
search, first page of a scroll 200, failed 1, the other shard's hits 400 Partial shards failure
search with a shard which isn't available 200. Elasticsearch: failed 1 (no_shard_available_action_exception). OpenSearch: successful 1 of 2, failed 0, no failure listed 503 (Search rejected due to missing shards)
later page of a scroll 200, failed 1, the other shard's hits, also where the scroll was opened with the parameter 400 unrecognized parameter
page of a point in time (Elasticsearch only) 200, failed 1, the other shard's hits 400, or 404 for the deleted index
count (_count) 200, the other shard's count. OpenSearch with a shard which isn't available: failed 0 400 unrecognized parameter
aggregation (max) 200, the other shard's maximum 400 Partial shards failure
  • The parameter alone doesn't cover the later pages of a scroll, which reject it, nor Elasticsearch 6.
  • The check alone would cover every answer, but the parameter adds two things:
    • Elasticsearch fails the request with a status, such as 503 for a shard which isn't available. retry-error-codes classifies that status, so a transient failure is reattempted like any other. The check's IOException has no status, and is permanent.
    • A shard which runs out of a search's timeout fails, rather than answering with the hits it found in time. The timeout is one which a direct index query sets as a parameter, or the cluster's search.default_search_timeout.

Why counts go through the search API

  • _count rejects allow_partial_search_results, so a count which a shard failed could only fail through the check, with a permanent IOException. A search failed for a shard which isn't available got a 503 and was reattempted, while a count of the same query failed at once.
  • _count doesn't tell that a shard ran out of time. With the cluster's search.default_search_timeout at 1 ms and a slow script query over 20,000 documents, _count answered 11,533 on Elasticsearch 9.5.4, and 14,336, 12,288 and 18,777 on Elasticsearch 7.17.8, OpenSearch 3.9.0 and Elasticsearch 6.6.0, each with every shard successful. The same count through _search with the parameter failed with search_timeout_exception on Elasticsearch 9.5.4 (429) and 8.15.3 (504).
  • The count API runs the same request in the versions inspected: RestCountAction builds a SearchRequest with size(0), trackTotalHits(true) and the terminate_after of the count in Elasticsearch 7.17, 8.15 and 9.5 and OpenSearch 1.3 and 3.3. Elasticsearch 6.6 builds size(0) without trackTotalHits(true), as Elasticsearch 6 counts the total by default. The totals agreed with _count on Elasticsearch 6.6.0, 7.17.8, 8.15.3 and 9.5.4 and OpenSearch 2.19.6 and 3.9.0, with and without terminate_after.
  • The cost: on Elasticsearch 6.6.0, 7.17.8, 8.15.3 and 9.5.4 and OpenSearch 2.19.6 and 3.9.0, 300 counts of each of three queries over 20,000 documents, alternating between the two APIs over one connection, took medians of 0.55 to 2.55 ms through _search. That is 0.4% to 10% (0.004 to 0.23 ms) more than through _count. Both run the same search, so the difference lies in the REST layer, which parses a search body and writes the answer of a search.

Why an option

  • Elasticsearch allows partial results unless told otherwise, and JanusGraph used them until now. A deployment may prefer them during a partial outage, as a direct index query for full-text search which should keep answering while a shard isn't available, or which should answer with what it found in time. Without the option, such a deployment would have to stay on the previous version.
  • It stays simple: one boolean on the client, which skips the parameter and the check. It is MASKABLE, so a single instance can use partial results while the others fail.
  • It is off by default: a traversal over a partial result is wrong without an error. A count() is too low, a drop() leaves elements behind, and a result ends early.

Tests

  • RestClientShardFailureTest (new, 18 tests), on a mocked RestClient, with the failures which Elasticsearch 9.5.4 reported in the experiments above, shortened:
    • fails each of these answers, and checks the message:
      • a search with a failed shard (script field), whose message names the cause too;
      • a page of a scroll whose shard lost its context;
      • a page of a point in time with a shard which isn't available;
      • a search answered as OpenSearch answers on an index with a shard which isn't available (successful 1 of 2, failed 0);
      • a count, and an aggregation with two failures;
      • a search, a count and an aggregation whose shards ran out of time (timed_out);
    • uses the answers of every shard, skipped shards among them, and an answer without _shards;
    • uses the partial answers, and those found in time, where the option allows them;
    • sends allow_partial_search_results=false to Elasticsearch 7, 8 and 9 with a search, the first page of a scroll, a page of a point in time, a count and an aggregation, but not with a later page of a scroll; never to Elasticsearch 6; never where partial results are allowed. The first page of a scroll keeps its scroll parameter, and the count goes to _search.
  • RestClientCountAndAggregationTest: a count is a search of size 0 with track_total_hits, which keeps terminate_after where the count is bounded. It reads the total of Elasticsearch 7 and later, an object, and of Elasticsearch 6, a number.
  • ElasticSearchExceptionConversionTest: the errors of a search which ran out of time convert to permanent, with the bodies Elasticsearch answered: status 429 and 504 (search_timeout_exception), and, in a case of its own, 500 (query_phase_execution_exception "Time exceeded") where 500 is configured as transient. A search rejected with 429 (es_rejected_execution_exception) stays temporary.
  • RestClientRetryTest: the client doesn't reattempt a search which ran out of time, though its status 429 is listed.
  • ElasticSearchScrollTest and ElasticSearchPointInTimeTest: a later page which fails, with an IOException or at runtime, releases the scroll or closes the point in time once, and every later call fails with the same exception.
  • SubqueryIteratorTest (janusgraph-core): shouldNeitherProbeNorCacheAfterReadingTheIndexFailed reads an index stream whose ids fail after two and which then answers that they ended, as the point in time did on master. Closing the iterator neither throws, nor asks the stream for another id, nor caches anything, and still closes the stream.
  • ElasticSearchFailedPageCacheTest (new): a scroll and a point in time whose second page fails are read through a real SubqueryIterator and CaffeineSubqueryCache, as a graph query reads them. The read fails, closing the iterator doesn't throw, and the cache holds nothing.
  • RestClientSetupTest: the option reaches the client, false by default and true where configured.
  • ElasticsearchIndexTest (Elasticsearch container), on an index of two shards: 10 documents, read in the order of time in pages of 3.
    • shouldFailAResultWhichSomeShardsLeftOut, in each paging mode, with a script field which throws for doc7: a single request of 8 hits throws a BackendException, and reading the whole result throws when it reaches the third page. Both failures name the script's exception.
    • shouldEndAResultEarlyWherePartialResultsAreAllowed, in each paging mode: with the option, the result ends at the third page, as before, without doc7 and doc9.
    • shouldFailACountWhichSomeShardsLeftOut: a count whose post_filter throws for doc7 fails, naming the script's exception. shouldCountWhatTheShardsWhichAnsweredFoundWherePartialResultsAreAllowed: with the option, it counts fewer than the 10 documents.

Mutation checks, each with the rest of the fix in place:

  • Without the check: nine tests of RestClientShardFailureTest fail with Expected java.io.IOException to be thrown, but nothing was thrown, one for each kind of answer above.
  • Checking failed alone, without successful below total: the test of the answer OpenSearch gives fails.
  • Without the check of timed_out: the three tests of answers which ran out of time fail. Without it for aggregations alone: the test of the aggregation fails.
  • Without the parameter: the test of the parameter fails for Elasticsearch 7, 8 and 9 with expected: <[false, false, null, false, false, false]> but was: <[null, null, null, null, null, null]>.
  • With the parameter also where partial results are allowed, or also to Elasticsearch 6: the test of that case fails.
  • With the count through _count: the test of the parameter fails for the count (null), and the count test with expected: </store/_search> but was: </store/_count>.
  • Reattempting a time-out in the client: testSearchWhichRanOutOfTimeIsNotRetried fails, Wanted 1 time ... But was 4 times. Classifying it as transient: the three conversion tests of time-outs fail with expected: <org.janusgraph.diskstorage.PermanentBackendException> but was: <org.janusgraph.diskstorage.TemporaryBackendException>. Recognising search_timeout_exception alone: the test of the answer of Elasticsearch 7 (500) fails so.
  • Without the release of the scroll: both new ElasticSearchScrollTest cases fail with Wanted but not invoked: elasticSearchClient.deleteScroll("scrollId").
  • Without the flag of SubqueryIterator, or with close() ignoring it: shouldNeitherProbeNorCacheAfterReadingTheIndexFailed fails with expected: <false> but was: <true>, as the stream is asked for another id after its failure. Both ElasticSearchFailedPageCacheTest cases fail, as closing the iterator throws the failure of the page again: Elasticsearch answered a page of a scroll with the results of only 1 of 2 shards and Could not read the next page of point in time pit-1.
  • With a scroll or a point in time which answers that it ended after a failed page, as the point in time did on master: its two failure tests fail with Expected ... to be thrown, but nothing was thrown. Its cache test still passes, as the SubqueryIterator no longer asks after a failed read.
  • Without the option passed to the client: the two RestClientSetupTest cases fail with Wanted but not invoked: restElasticSearchClientMock.setAllowPartialResults(...).
  • Without the check, the parameter and the release, and with the count through _count, which reads as master does, against Elasticsearch 9.5.4:
    • shouldFailAResultWhichSomeShardsLeftOut fails in all three paging modes with Expected org.janusgraph.diskstorage.BackendException to be thrown, but nothing was thrown, as the single request returns the hits of the other shard.
    • Both count tests fail, as _count rejects the post_filter with 400 rather than failing for the shard.
    • shouldEndAResultEarlyWherePartialResultsAreAllowed passes there, as it describes master's behaviour.

Suites, locally with Java 11, rebased onto #5075 (75aeea1c2), with checkstyle on:

  • The unit tests of janusgraph-core (233) and janusgraph-driver (130) pass.
  • Against Elasticsearch 9.5.4 in a container: ElasticsearchIndexTest (314), BerkeleyElasticsearchTest (117, 1 skipped) and the PR's 8 unit test classes (118) pass.

Before the rebase, locally with Java 11 on 3285cbf18:

  • With checkstyle on, against Elasticsearch 9.5.4 in a container: ElasticsearchIndexTest (314), ElasticsearchConfigTest (12), BerkeleyElasticsearchTest (117, 1 skipped) and the module's 23 unit test classes (242) pass.
  • With checkstyle on: the unit tests of janusgraph-core (229) and janusgraph-driver (130), and InMemoryGraphTest (219, 42 skipped), pass.
  • Against Elasticsearch 6.6.0, 7.17.8 and 8.15.3 and OpenSearch 2.19.6 and 3.9.0, the versions of the module's test profiles, these 10 tests of ElasticsearchIndexTest pass:
    • the new tests of failed shards and of counts;
    • shouldCountZeroForALimitOfZero;
    • testReadFailureWithAStatusInRetryErrorCodesIsTransient, which fails a count with a 400 and a 404.

Notes

  • Behaviour changes:
    • A read fails where it used to return partial results. Every query which reaches a shard that isn't available now fails. Elasticsearch answers such a search with 503, which retry-error-codes lists by default, so the client reattempts it retry-limit times (3 by default). Where the index query sends it before it returns its stream (its first request, and the first page of a scroll or of a point in time), BackendOperation also reattempts the index query within storage.read-time (10 s by default) before it fails.
    • A search or count which runs out of a timeout fails, where it returned the hits found in time. JanusGraph doesn't reattempt it; the low-level RestClient still sends one answered with 504, as on Elasticsearch 8.15.3, to its next host.
    • A count of a direct index query now applies the query's parameters, such as min_score. The count API rejected a body with anything but the query (request does not support [...]), so such a count failed before.
  • A failure which only the check finds is an IOException without a status, so it is permanent: a later page of a scroll, or any answer of Elasticsearch 6. The later pages are read outside BackendOperation anyway.
  • On Elasticsearch 6, the first page of a scroll which the check fails leaves its scroll context to expire after scroll-keep-alive. Elasticsearch 7 and later fail such a page themselves and free its contexts. Releasing it would take more than a line or two, for an end-of-life version.
  • Where several hosts are configured, the low-level RestClient sends a request answered with 502, 503 or 504 to each host before it fails, and takes each for dead for a while: a search on an index with a shard which isn't available (503), and a search which ran out of time on Elasticsearch 8.15.3 (504).
  • No JMH benchmark: the change adds one URL parameter to each request to the search API, and parses _shards into a small object where it parsed it into a map. The cost of counts through the search API is measured above.

🤖 Generated with Claude Code

@porunov
porunov requested a balanced review from Copilot October 15, 2026 18:07

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

3 open findings
What changed in this PR

This PR makes Elasticsearch/OpenSearch reads fail when responses contain partial shard results (or time out), unless a new index.[X].elasticsearch.allow-partial-results option is enabled, preventing silent truncation in searches/scroll/PIT paging, counts, and aggregations.

Changes:

  • Add shard/timed-out parsing (_shards, timed_out) and enforce completeness checks across search/scroll/PIT/count/agg responses.
  • Send allow_partial_search_results=false on supported search-API requests (ES 7+ / OpenSearch) and route counts through _search to support that behavior.
  • Harden paging/caching behavior after page failures (scroll/PIT contexts released; SubqueryIterator avoids probing/caching after failures) and add extensive test coverage + docs.
File Description
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestElasticSearchClient.java Adds allow_partial_search_results parameter injection and enforces shard completeness/time-out checks for reads.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestShardStatistics.java New model for _shards parsing and failure description formatting.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestSearchResponse.java Parses _shards and timed_out for search-like responses.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestCountResponse.java Updates count parsing to read totals from search-style hits.total and shard/timed-out info.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestAggResponse.java Adds shard and timed_out parsing for aggregation responses.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestClientSetup.java Wires new allow-partial-results option into the REST client.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​TransientFailures.java Detects search timeouts from response bodies to avoid retrying them.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchScroll.java Ensures failed scroll pages release the scroll context and rethrow consistently.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchPointInTime.java Ensures failed PIT pages close PIT and rethrow consistently.
janusgraph-es/​src/​main/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchIndex.java Introduces config option and updates retry classification to exclude search timeouts.
janusgraph-core/​src/​main/​java/​org/​janusgraph/​graphdb/​util/​SubqueryIterator.java Prevents probing/caching after index read failure to avoid masking the original exception.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestClientShardFailureTest.java New unit tests validating shard failure/time-out handling and parameter sending behavior.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestClientCountAndAggregationTest.java Updates tests for count-via-search behavior and ES6 vs ES7+ total parsing.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestClientRetryTest.java Adds test ensuring search timeouts aren’t retried even if status is in retry codes.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchExceptionConversionTest.java Adds timeout-body coverage for permanent vs temporary exception conversion.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchFailedPageCacheTest.java New tests ensuring failed paging results are not cached in transaction subquery cache.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​ElasticsearchIndexTest.java Integration tests for shard failures and allow-partial-results behavior on real clusters.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchScrollTest.java Tests that failed later scroll pages release context and rethrow on subsequent calls.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​ElasticSearchPointInTimeTest.java Tests that failed later PIT pages close PIT and rethrow on subsequent calls.
janusgraph-es/​src/​test/​java/​org/​janusgraph/​diskstorage/​es/​rest/​RestClientSetupTest.java Verifies new config option is mapped into the client with correct default.
docs/​index-backend/​elasticsearch.md Documents new failure semantics and allow-partial-results option.
docs/​configs/​janusgraph-cfg.md Adds configuration reference entry for allow-partial-results and updates related option text.
docs/​changelog.md Adds changelog section describing behavioral change and new option.

🧠 Review effort: Lite


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

Comment thread janusgraph-core/src/main/java/org/janusgraph/graphdb/util/SubqueryIterator.java Outdated

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

2 open findings
3 resolved since last review

🧠 Review effort: Lite

Fixes JanusGraph#5095

Elasticsearch answers a search, a page of a scroll or of a point in
time, a count or an aggregation which some shards fail with the results
of the others, and fails it only when every shard failed. A shard fails
where, for example, its search context expired on its node but not on
the others, it isn't available, or it rejects the request, and a shard
which runs out of a search's time answers with the hits it found until
then. JanusGraph read _shards.total but never _shards.failed, so it took
such an answer as complete: ElasticSearchScroll and
ElasticSearchPointInTime took a page with fewer hits than its size for
the last one, which ended the result early, and a count or an
aggregation left the failed shards out, all without an error.

RestElasticSearchClient now fails every answer whose _shards report
shards that didn't answer, failed ones or ones counted neither as
successful nor as failed, which is how OpenSearch reports a shard that
isn't available, and every answer which ran out of time. The IOException
names the index, shard, type and reason of each failure listed. The
client also sends allow_partial_search_results=false with every request
to the search API (a search, the first page of a scroll, a page of a
point in time, a count, an aggregation) to Elasticsearch 7 and later and
to OpenSearch, which then fail it themselves with a status that
retry-error-codes classifies, such as 503 for a shard which isn't
available. A count is now a search for no hits which counts them all,
which is what the count API runs, as the count API rejects that
parameter; so do the later pages of a scroll, which only the check
covers.

JanusGraph doesn't reattempt a search which ran out of time, whatever
its status, 429 on Elasticsearch 9.5.4 and 504 on 8.15.3:
TransientFailures recognises search_timeout_exception, and
query_phase_execution_exception "Time exceeded" of Elasticsearch 7 and
OpenSearch, for RestElasticSearchClient and ElasticSearchIndex alike.
The low-level RestClient still sends a request answered with 502, 503 or
504 to its next host.

ElasticSearchScroll releases its context when a page fails, as
ElasticSearchPointInTime closes its point in time, and both rethrow the
failure of a page on every later call rather than answer that the result
ended. SubqueryIterator, closed after reading the index failed, as
closing a traversal does, no longer asks the index whether the result
ended. That could fail again, and throw that failure from the close,
wrapped, in place of the read's, skipping the closes after it. Nor does
it cache the ids read before the failure as the whole result, as it
could after a page of a point in time failed.

The new MASKABLE option index.[X].elasticsearch.allow-partial-results,
false by default, uses the results of the shards which answered instead,
and the hits found before a search ran out of time, as before.

Signed-off-by: Oleksandr Porunov <alexandr.porunov@gmail.com>
Co-authored-by: Oleksandr Porunov <alexandr.porunov@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@porunov
porunov force-pushed the es-fail-on-failed-shards branch from 425869e to cf0bf44 Compare October 16, 2026 13:33
@porunov
porunov requested a balanced review from Copilot October 16, 2026 13:34

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.

Comment on lines +81 to +95
public static boolean isSearchTimeout(Throwable failure) {
if (!(failure instanceof ResponseException)) {
return false;
}
final Response response = ((ResponseException) failure).getResponse();
final HttpEntity entity = response == null ? null : response.getEntity();
if (entity == null) {
return false;
}
try {
return mentionsTimeout(MAPPER.readValue(EntityUtils.toString(entity), Object.class));
} catch (IOException | RuntimeException notJson) {
return false;
}
}
Comment on lines +31 to +40
private int total;

//Shards which were skipped because they can't match are counted here too
private int successful;

private int failed;

//One for each index, type and reason of failure, as Elasticsearch groups them. OpenSearch counts a shard which
//wasn't available neither among the successful nor among the failed shards, and lists no failure for it
private List<Map<String, Object>> failures;
Comment on lines +77 to +79
public boolean isComplete() {
return failed == 0 && successful >= total;
}
final Request request = new Request(REQUEST_TYPE_GET, REQUEST_SEPARATOR + indexName + REQUEST_SEPARATOR + "_count");
//A search for no hits which counts them all, which is what the count API runs. The search API can be told not
//to allow partial results, which the count API rejects
final Request request = searchRequest(REQUEST_TYPE_GET,

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.

Comment on lines +81 to +95
public static boolean isSearchTimeout(Throwable failure) {
if (!(failure instanceof ResponseException)) {
return false;
}
final Response response = ((ResponseException) failure).getResponse();
final HttpEntity entity = response == null ? null : response.getEntity();
if (entity == null) {
return false;
}
try {
return mentionsTimeout(MAPPER.readValue(EntityUtils.toString(entity), Object.class));
} catch (IOException | RuntimeException notJson) {
return false;
}
}
final Request request = new Request(REQUEST_TYPE_GET, REQUEST_SEPARATOR + indexName + REQUEST_SEPARATOR + "_count");
//A search for no hits which counts them all, which is what the count API runs. The search API can be told not
//to allow partial results, which the count API rejects
final Request request = searchRequest(REQUEST_TYPE_GET,

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.

Elasticsearch index queries end early or leave hits out without an error when some shards fail

2 participants