Skip to content

Elasticsearch: transient failures classified as PermanentBackendException, so index mutations are silently dropped instead of retried #4925

Description

@batrived
  • Version: master (ac0eb23) — also all released versions; convert() has had this shape since the Titan era
  • Storage Backend: any (behaviour is not backend-specific)
  • Mixed Index Backend: elasticsearch
  • Expected Behavior: transient Elasticsearch failures (HTTP 429/502/503/504, connection reset, socket timeout) should be classified as TemporaryBackendException, so that the retry loop already present in BackendOperation absorbs them within the storage.write-time budget.
  • Current Behavior: every exception except InterruptedException is classified as PermanentBackendException, so transient failures are never retried. The index mutation is dropped, and because index mutations are applied after storage in StandardJanusGraph.commit(), the failure cannot be rolled back — it is only logged, leaving the mixed index permanently inconsistent with the graph.

Details

ElasticSearchIndex.convert() collapses every failure mode into one bucket:

private BackendException convert(Exception esException) {
if (esException instanceof InterruptedException) {
return new TemporaryBackendException("Interrupted while waiting for response", esException);
} else {
return new PermanentBackendException("Unknown exception while executing index operation", esException);
}
}

private BackendException convert(Exception esException) {
    if (esException instanceof InterruptedException) {
        return new TemporaryBackendException("Interrupted while waiting for response", esException);
    } else {
        return new PermanentBackendException("Unknown exception while executing index operation", esException);
    }
}

The retry machinery already exists and is already wired up, so this is a classification fix rather than new functionality. IndexTransaction.flushInternal() runs the mutation through BackendOperation.execute(..., maxWriteTime):

private void flushInternal() throws BackendException {
if (mutations!=null && !mutations.isEmpty()) {
//Consolidate all mutations prior to persistence to ensure that no addition accidentally gets swallowed by a delete
for (Map<String, IndexMutation> store : mutations.values()) {
for (IndexMutation mut : store.values()) mut.consolidate();
}
BackendOperation.execute(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
index.mutate(mutations, keyInformation, indexTx);
return true;
}
@Override
public String toString() {
return "IndexMutation";
}
}, maxWriteTime);

and BackendOperation.executeDirect implements jittered exponential backoff — but only when the innermost BackendException is a TemporaryBackendException:

public static <V> V executeDirect(Callable<V> exe, Duration totalWaitTime) throws BackendException {
Preconditions.checkArgument(!totalWaitTime.isZero(),"Need to specify a positive waitTime: %s",totalWaitTime);
long maxTime = System.currentTimeMillis()+totalWaitTime.toMillis();
Duration waitTime = pertubTime(BASE_REATTEMPT_TIME);
BackendException lastException;
while (true) {
try {
return exe.call();
} catch (final Throwable e) {
//Find inner-most StorageException
Throwable ex = e;
BackendException storeEx = null;
do {
if (ex instanceof BackendException) storeEx = (BackendException)ex;
} while ((ex=ex.getCause())!=null);
if (storeEx!=null && storeEx instanceof TemporaryBackendException) {
lastException = storeEx;
} else if (e instanceof BackendException) {
throw (BackendException)e;
} else {
throw new PermanentBackendException("Permanent exception while executing backend operation "+exe.toString(),e);
}
}
//Wait and retry
assert lastException!=null;
if (System.currentTimeMillis()+waitTime.toMillis()<maxTime) {
log.info("Temporary exception during backend operation ["+exe.toString()+"]. Attempting backoff retry.",lastException);
try {
Thread.sleep(waitTime.toMillis());
} catch (InterruptedException r) {
// added thread interrupt signal to support traversal interruption
Thread.currentThread().interrupt();
throw new PermanentBackendException("Interrupted while waiting to retry failed backend operation", r);
}
} else {
break;
}
waitTime = pertubTime(waitTime.multipliedBy(2));
}
throw new TemporaryBackendException("Could not successfully complete backend operation due to repeated temporary exceptions after "+totalWaitTime,lastException);

So today:

  • ES returns 429 es_rejected_execution_exception (write threadpool queue full) → PermanentBackendException → no retry → mutation dropped.
  • ES returns 502/503/504 (shard unavailable, gateway restart, rolling upgrade) → same.
  • Socket timeout or connection reset mid-bulk → same.

Because commit() commits storage first and then collects index failures rather than aborting:

//2. Commit indexes - [FAILURE] all exceptions are collected and logged but nothing is aborted
indexFailures = mutator.commitIndexes();
if (!indexFailures.isEmpty()) {
status = LogTxStatus.SECONDARY_FAILURE;
for (Map.Entry<String,Throwable> entry : indexFailures.entrySet()) {
log.error("Error while committing index mutations for transaction ["+transactionId+"] on index: " +entry.getKey(),entry.getValue());
}
}

the dropped mutation cannot be undone. Unless log-tx is enabled (default false) and a transaction-recovery processor is running, the divergence is permanent and the only signal is a single ERROR log line.

The net effect is that a brief and entirely routine Elasticsearch event — a rolling restart, a hot shard rejecting a bulk, a GC pause causing a socket timeout — permanently desynchronizes a mixed index, in a situation the existing retry loop was designed to absorb.

Steps to Reproduce

  1. Configure a graph with an Elasticsearch mixed index.
  2. Make ES return a retryable error for a bulk request — e.g. saturate the write threadpool queue so bulk items fail with es_rejected_execution_exception, or interpose a proxy that returns 503 for one request.
  3. Commit a transaction that writes to that mixed index.
  4. Observe: Error while committing index mutations for transaction [...] is logged once, no retry is attempted, and the document is absent from ES while the element is present in the storage backend.

Suggested Fix

Classify on transport / HTTP status rather than a single instanceof:

  • TemporaryBackendException: HTTP 429, 502, 503, 504; ConnectException, SocketTimeoutException, ConnectionClosedException, NoHttpResponseException; InterruptedException (unchanged)
  • PermanentBackendException: HTTP 400 (including mapper_parsing_exception), 401/403, 404, and anything unrecognised

The ES REST client exposes the status via ResponseException.getResponse().getStatusLine().getStatusCode(), so this can be handled entirely inside convert() without touching call sites. Making the retryable status set configurable would additionally let operators adapt to a new failure mode without waiting for a release.

Glad to open a PR for this if the approach sounds reasonable.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions