Retry transient Elasticsearch failures instead of dropping the index mutation (#4925) - #4935
Open
batrived wants to merge 1 commit into
Open
Conversation
8 tasks
batrived
force-pushed
the
fix/4925-es-transient-exception-classification
branch
from
August 11, 2026 23:19
b100cce to
6b06124
Compare
…mutation (JanusGraph#4925) ElasticSearchIndex reported every failure except an interrupt as a PermanentBackendException. A commit applies index mutations after the storage mutations, and cannot roll them back. A transient failure therefore dropped the mutation with one ERROR log line. Examples are a rolling restart, a saturated write queue, and a socket timeout during a GC pause. The mixed index then stayed inconsistent with the graph until a reindex or a transaction-log recovery repaired it. Classify such a failure as a TemporaryBackendException. BackendOperation already reattempts that exception with exponential backoff for up to storage.write-time. Two new options control the classification, and both are enabled by default. temporary-error-codes lists the HTTP status codes which are transient (429, 502, 503 and 504). temporary-transport-failures covers the failures which produce no HTTP response, and which no status code can match. A bulk request reports item level failures inside a successful HTTP response, so the enclosing response gives no status to classify on. Retain the item statuses in the new ElasticSearchBulkFailureException. Reattempt the request only if every item which failed did so transiently, because a batch which holds a permanently failing item cannot succeed on a reattempt. A mapping conflict is one such item. Restore the interrupt status of the thread after the InterruptedException has been consumed. BackendOperation needs that status to abort its backoff wait. Without it, a cancelled commit continued to reissue the bulk request for the whole write time budget. Log a transient failure at WARN, and keep ERROR for a mutation which is about to be dropped. Signed-off-by: Balmukund Trivedi <btrivedipublic@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
batrived
force-pushed
the
fix/4925-es-transient-exception-classification
branch
from
August 13, 2026 17:10
6b06124 to
746d207
Compare
There was a problem hiding this comment.
Pull request overview
This PR improves JanusGraph’s Elasticsearch mixed-index write reliability by classifying transient Elasticsearch failures as TemporaryBackendException so that the existing BackendOperation retry loop can reattempt index mutations (instead of dropping them and leaving the index inconsistent).
Changes:
- Add configurable transient-failure classification for Elasticsearch index operations (HTTP status codes + transport failures) and restore thread interrupt status when applicable.
- Preserve bulk item status codes via a dedicated
ElasticSearchBulkFailureExceptionso callers can decide whether a bulk failure is entirely transient. - Add unit tests for exception conversion and bulk-item-status retention; document new options in generated config docs and changelog.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| janusgraph-es/src/main/java/org/janusgraph/diskstorage/es/ElasticSearchIndex.java | Adds transient/permanent classification, new config options, interrupt-status restoration, and WARN vs ERROR logging at the index layer. |
| janusgraph-es/src/main/java/org/janusgraph/diskstorage/es/rest/RestElasticSearchClient.java | Throws a richer exception for bulk item failures so callers can classify them by item status codes. |
| janusgraph-es/src/main/java/org/janusgraph/diskstorage/es/ElasticSearchBulkFailureException.java | New exception type carrying distinct failed bulk-item HTTP status codes. |
| janusgraph-es/src/test/java/org/janusgraph/diskstorage/es/ElasticSearchExceptionConversionTest.java | New tests covering cause-chain inspection, status/transport classification, interrupt handling, and retry behavior via BackendOperation. |
| janusgraph-es/src/test/java/org/janusgraph/diskstorage/es/rest/RestClientRetryTest.java | Adds coverage ensuring failed bulk items retain their status codes. |
| docs/configs/janusgraph-cfg.md | Documents the two new Elasticsearch retry-classification options. |
| docs/changelog.md | Adds a changelog entry describing the new retry behavior, defaults, and LIST-cardinality duplication caveat. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
571
to
+575
| final List<Object> errorItems = bulkItemsThatFailed.stream().map(Triplet::getValue0).collect(Collectors.toList()); | ||
| errorItems.forEach(error -> log.error("Failed to execute ES query: {}", error)); | ||
| throw new IOException("Failure(s) in Elasticsearch bulk request: " + errorItems); | ||
| //Retain the item statuses so callers can classify the failure as transient or permanent | ||
| throw new ElasticSearchBulkFailureException( | ||
| "Failure(s) in Elasticsearch bulk request: " + errorItems, errorCodes); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4925.
Problem
ElasticSearchIndex.convertreported every failure except an interrupt as aPermanentBackendException. A commit applies index mutations after the storage mutations, and cannot roll them back.BackendOperationreattempts only aTemporaryBackendException, so a transient failure dropped the mutation after a single attempt and wrote one ERROR log line. The mixed index then stayed inconsistent with the graph until a reindex or a transaction-log recovery repaired it.A rolling restart, a saturated write queue, or a socket timeout during a GC pause is enough to cause this. The existing
retry-error-codesoption does not help, because it is empty by default, it acts only inside the Elasticsearch client, and it can match only a failure which produced an HTTP response.Change
A transient failure is now a
TemporaryBackendException, whichBackendOperationreattempts with exponential backoff for up tostorage.write-time(default 100s). This happens after theretry-limitattempts of the Elasticsearch client are exhausted.Two new options control the classification. Both are enabled by default:
index.[X].elasticsearch.temporary-error-codes429,502,503,504index.[X].elasticsearch.temporary-transport-failurestrueClear both options to keep the previous behavior.
Three further points:
ElasticSearchBulkFailureExceptioncarries those statuses. The request is reattempted only if every item which failed did so transiently, because a batch which holds a permanently failing item, such as a mapping conflict, cannot succeed on a reattempt.RestElasticSearchClient.performRetryWaitwraps anInterruptedExceptionin aRuntimeException, andThread.sleephad already cleared the status.BackendOperationneeds that status to abort its backoff wait, soconvertnow restores it. Without this the wider cause-chain inspection would let a cancelled commit reissue the bulk request for the whole write time budget.The classification walks the cause chain, because the Elasticsearch client rewraps the failure. I verified against
RestClient.extractAndWrapCausein 9.0.3 that the four exception types matched byisTransportFailurecover every transport failure the client produces (ConnectException,SocketTimeoutException,ConnectTimeoutExceptionandConnectionClosedException), and that both aResponseExceptionand anInterruptedExceptionreach the caller wrapped.Reviewers, please note
storage.write-timeinstead of failing fast. This trades commit latency for index consistency. I am happy to default them off if that trade is not the one you want.SETcardinality properties, but the values of aLISTcardinality property are appended, so they can be duplicated. The changelog and both option descriptions state this. Scoping the reattempt to the failed items only would need the surviving item state to be threaded back out ofbulkRequest, which felt out of scope here.Known gaps, kept out of scope
Happy to fold any of these in, or to open separate issues:
IndexTransaction.restorecallsindex.restore()directly rather than throughBackendOperation, unlikeflushInternal. So the reindex and transaction-log recovery paths get the new classification but nothing reattempts them.RestElasticSearchClientstill logs one ERROR per failed bulk item, which works against the log level change above.temporary-error-codesvalue fails graph open with a bareNumberFormatException. The same parse already exists inRestClientSetup, so a shared helper or aConfigOptionverification function would suit both.SolrIndexandLuceneIndexhave the same "every failure is permanent" behavior. A backend-agnostic classifier next toBackendOperationwould fix all index providers at once.Separately, and unrelated to this change:
MetricInstrumentedIndexProvider.runWithMetrics(BaseTransactionConfigurable, String, StorageRunnable)is missing areturnin its!hasGroupName()branch, somutateandrestorerun twice. The siblingStorageCallableoverload doesreturn impl.call(). It is latent in the default configuration, becausemetrics.prefixis non-null. I will raise it separately unless you would rather see it here.For all changes:
master)?For code changes:
For documentation related changes:
docs/configs/janusgraph-cfg.mdis regenerated byjanusgraph-doc, so it matchesConfigurationPrinteroutput exactly.