Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@
import static org.apache.hugegraph.store.client.util.HgStoreClientConst.TX_SESSIONS_MAP_CAPACITY;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;
Expand All @@ -46,6 +50,8 @@
import org.apache.hugegraph.store.term.HgPair;
import org.apache.hugegraph.store.term.HgTriple;

import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import lombok.extern.slf4j.Slf4j;

/**
Expand All @@ -58,8 +64,6 @@ final class NodeTxExecutor {
private static final String maxTryMsg =
"the number of retries reached the upper limit : " + NODE_MAX_RETRYING_TIMES +
",caused by:";
private static final String msg =
"Not all tx-data delivered to real-node-session successfully.";

static {
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism",
Expand Down Expand Up @@ -121,43 +125,11 @@ void doCommit() {
if (this.entries.isEmpty()) {
return true;
}
AtomicBoolean allSuccess = new AtomicBoolean(true);
for (HgPair<HgTriple<String, HgOwnerKey, Object>, Function<NodeTkv, Boolean>> e :
this.entries) {
doAction(e.getKey(), e.getValue());
}
if (!allSuccess.get()) {
throw HgStoreClientException.of(msg);
}
AtomicReference<Throwable> throwable = new AtomicReference<>();
Collection<HgStoreSession> sessions = this.sessions.values();
sessions.parallelStream().forEach(e -> {
if (e.isTx()) {
try {
e.commit();
} catch (Throwable t) {
throwable.compareAndSet(null, t);
allSuccess.set(false);
}
}
});
if (!allSuccess.get()) {
if (isTx) {
try {
sessions.stream().forEach(HgStoreSession::rollback);
} catch (Exception e) {

}
}
Throwable cause = throwable.get();
if (cause.getCause() != null) {
cause = cause.getCause();
}
if (cause instanceof HgStoreClientException) {
throw (HgStoreClientException) cause;
}
throw HgStoreClientException.of(cause);
}
this.commitSessions(this.sessions.values());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Extracting commitSessions() removed the only writer of allSuccess, leaving a dead variable and an unreachable branch.

Evidence:

  • AtomicBoolean allSuccess = new AtomicBoolean(true) (:131) is now only ever read. The allSuccess.set(false) that used to live in the inline parallel-commit block moved into commitSessions(), which reports through the thrown exception instead.
  • So if (!allSuccess.get()) { throw HgStoreClientException.of(msg); } (:136-138) can never fire, and msg is referenced only from that unreachable throw.

Requested change: delete :131 and :136-138 (and the now-unused msg field, plus the java.util.concurrent.atomic.AtomicBoolean import, if nothing else uses them). If the gate was meant to catch a doAction() that returned false, wire the loop's return value into it instead of leaving it dead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 35e0a6dallSuccess, the unreachable throw and the unused msg constant are gone (the doAction() return value was never wired into that gate before either, so nothing changes behaviourally).

return true;
});

Expand Down Expand Up @@ -232,6 +204,52 @@ void doCommit() {
// }
// };

/**
* Commit every tx session in parallel. When at least one commit fails, roll back (in tx
* mode) and throw one exception that carries ALL failures: the first one as the cause, the
* others as suppressed. The retry loop looks at all of them, so whether a commit is retried
* no longer depends on which partition happened to fail first.
*/
void commitSessions(Collection<HgStoreSession> sessions) {
Queue<Throwable> failures = new ConcurrentLinkedQueue<>();
sessions.parallelStream().forEach(e -> {
if (e.isTx()) {
try {
e.commit();
} catch (Throwable t) {
failures.add(t);
}
}
});
if (failures.isEmpty()) {
return;
}
if (isTx) {
try {
sessions.stream().forEach(HgStoreSession::rollback);
} catch (Exception e) {
// keep the commit failure as the reported one
}
}
throw aggregate(failures);
}

static HgStoreClientException aggregate(Collection<Throwable> failures) {
Iterator<Throwable> it = failures.iterator();
Throwable first = it.next();
Throwable cause = first.getCause() != null ? first.getCause() : first;
HgStoreClientException result = cause instanceof HgStoreClientException ?
(HgStoreClientException) cause :
HgStoreClientException.of(cause);
while (it.hasNext()) {
Throwable other = it.next();
if (other != result && other != cause) {
result.addSuppressed(other);
}
}
return result;
}

private boolean doAction(HgTriple<String, HgOwnerKey, Object> nodeParams,
Function<NodeTkv, Boolean> action) {
if (nodeParams.getZ() == null) {
Expand Down Expand Up @@ -373,35 +391,75 @@ boolean ifAnyTrue(Supplier<Stream<HgPair<HgStoreNode, NodeTkv>>> nodeStreamSuppl
}

<T> Optional<T> retryingInvoke(Supplier<T> supplier) {
boolean[] deadlineRetried = {false};
return IntStream.rangeClosed(0, NODE_MAX_RETRYING_TIMES).boxed()
.map(
i -> {
if (Thread.currentThread().isInterrupted()) {
// The caller (e.g. a REST worker hitting
// restserver.request_timeout) gave up: stop
// retrying instead of holding its thread.
// InterruptedException as the root cause: the
// server's task cancel path recognises it
// (HugeException.isInterrupted()).
throw HgStoreClientException.of(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Two of the new interrupt exits drop the InterruptedException, so the server cannot tell them apart from a store failure.

Evidence:

  • :409-410 throws HgStoreClientException.of("Interrupted before retry " + i) with no cause. :456-458 uses the store failure t as the cause and discards e. Neither has an InterruptedException as its root cause.
  • The FATAL path keeps one. A blocking stub on an interrupted thread fails with CANCELLED, and the InterruptedException is its cause: grpc-stub 1.39.0 ClientCalls.blockingUnaryCall calls call.cancel("Thread interrupted", e).
  • HugeException.isInterrupted() only checks the root cause (HugeException.java:56-61). HugeTask.fail() relies on it so that a cancelled task is not recorded as failed (HugeTask.java:351-355). Take a task that is cancelled while its thread is between store calls or in the retry sleep. It now logs a WARN with a stack trace. If the worker reaches fail() before cancel() sets CANCELLED (:323 interrupts, :336 sets the status), the task is stored as FAILED and cancel() returns false, so DistributedTaskScheduler.cancel() does not save CANCELLED (:317-321). Before this change the loop swallowed the interrupt and carried on, so this path did not exist.

Requested change: make InterruptedException the root cause on both exits. For example, use HgStoreClientException.of("Interrupted before retry " + i, new InterruptedException()). In the sleep handler, use HgStoreClientException.of("Interrupted while waiting to retry: " + t.getMessage(), e) and attach t with addSuppressed. Then extend testInterruptStopsRetrying and testInterruptBeforeCallSkipsTheAttempt to assert the root cause.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 172b2d0 — both exits now have an InterruptedException as the root cause (HgStoreClientException.of("Interrupted before retry " + i, new InterruptedException()); in the sleep handler the InterruptedException is the cause and the store failure t is attached to it as suppressed). testInterruptStopsRetrying and testInterruptBeforeCallSkipsTheAttempt assert the root cause, the first one also the suppressed store failure. 10/10 locally.

"Interrupted before retry " + i,
new InterruptedException());
}
T buffer = null;
try {
buffer = supplier.get();
} catch (Throwable t) {
if (i + 1 <= NODE_MAX_RETRYING_TIMES) {
try {
int sleepTime;
// The first three times try once every second
if (i < 3) {
sleepTime = 1;
} else {
// Subsequent incremental
sleepTime = i - 1;
}
log.info("Waiting {} seconds " +
"for the next try.",
sleepTime);
Thread.sleep(sleepTime * 1000L);
} catch (InterruptedException e) {
log.error("Failed to sleep", e);
Failure failure = classify(t);
if (failure != Failure.DEADLINE) {
// a different failure in between means the
// next deadline is not "in a row" again
deadlineRetried[0] = false;
}
if (failure == Failure.FATAL) {
// The caller's thread was interrupted or the
// call was cancelled: fail fast.
log.warn("Not retrying after: {}",
t.getMessage(), t);
throw HgStoreClientException.of(
t.getMessage(), t);
}
if (failure == Failure.DEADLINE) {
// One retry: the NOT_WORK notice sent for the
// failed RPC reloads the partition leaders, so
// the next attempt can reach a new leader. A
// second deadline in a row would only wait the
// full deadline again on the same stalled store.
if (deadlineRetried[0]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 The comment and the javadoc say "a second deadline in a row", but the flag is never reset, so the code means "a second deadline anywhere in this invocation".

Evidence:

  • boolean[] deadlineRetried = {false} (:401) is set once at :441 and never cleared, including after an attempt that fails with something else or that succeeds partially.
  • Concretely: attempt 0 DEADLINE_EXCEEDED (retried), attempts 1-9 UNAVAILABLE (retried, ~38 s of backoff, partition leaders reloaded several times over), attempt 10 DEADLINE_EXCEEDED — the guard fires and the call aborts, even though these two deadlines are not consecutive and the stated rationale ("the NOT_WORK notice … reloads the partition leaders") applies just as much the second time.
  • The same wording is in the Failure javadoc at :478.

Requested change: pick one. Either reset deadlineRetried[0] = false whenever an attempt fails with a non-DEADLINE classification, which makes the code match "in a row"; or reword :433 and :478 to "a second deadline in this invocation" so the budget is honestly described as one deadline retry per call.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 35e0a6d — I kept the "in a row" semantics and made the code match: the budget flag is reset whenever an attempt fails with a non-DEADLINE classification, so deadline → UNAVAILABLE → deadline retries the second deadline too (testDeadlineBudgetResetsAfterAnotherFailure, 4 attempts). 11/11 locally.

log.warn("Not retrying a second deadline: {}",
t.getMessage(), t);
throw HgStoreClientException.of(
t.getMessage(), t);
}
} else {
deadlineRetried[0] = true;
log.warn("Deadline exceeded, retrying once in " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ The PR description still describes the pre-b588aa88 behaviour, so the numbers reviewers and the squash commit message will carry are wrong for this head.

Evidence:

  • The body says "Do not retry a failure whose cause chain carries Status.Code.DEADLINE_EXCEEDED … (isRetryable())". At this head a deadline is retried once (this block, plus the guard four lines down), and isRetryable() no longer exists: git grep -n isRetryable 172b2d0 -- hugegraph-store returns nothing — it is classify() / Failure now.
  • The before/after table's slowest write | 20.1 s was measured with grpc.timeout.seconds=20 under the never-retry behaviour, i.e. one deadline. With the single retry the worst case is two deadlines plus the 1 s backoff: about 41 s in that setup, and about 201 s on the shipped default grpc.timeout.seconds=100. That is still a large improvement over 11 × timeout + 38 s, but it is twice what the table claims.

Requested change: update the "Main Changes" bullet to describe the retry-once classification and rename isRetryable() to classify(), and either re-run the SIGSTOP measurement at 172b2d0 or annotate the table as taken at 920bbbdf.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — the description now describes classify() / Failure with the single deadline retry (and the reset after a different failure from 35e0a6d), and I re-ran the SIGSTOP measurement at 35e0a6d instead of annotating: with one deadline retry the REST outage under the 1 request/s load is 128 of 300 s (in 20 short windows) against 12 s for the never-retry head and 431 of 503 s for master, the slowest write is 45 s (two deadlines plus backoff) and the server is still healthy 0 s after the writer stops. The table in the body has all three columns and states that the old column is superseded.

"case the partition leader moved: {}",
t.getMessage());
}
if (i + 1 > NODE_MAX_RETRYING_TIMES) {
log.error(maxTryMsg, t);
throw HgStoreClientException.of(
t.getMessage(), t);
}
// The first three times try once every second,
// subsequent incremental
int sleepTime = i < 3 ? 1 : i - 1;
log.info("Waiting {} seconds for the next try.",
sleepTime);
try {
Thread.sleep(sleepTime * 1000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.addSuppressed(t);
throw HgStoreClientException.of(
"Interrupted while waiting to retry: " +
t.getMessage(), e);
}
}
return buffer;
}
Expand All @@ -411,6 +469,54 @@ <T> Optional<T> retryingInvoke(Supplier<T> supplier) {

}

/**
* How a failed attempt is treated by {@link #retryingInvoke}: FATAL is never retried (the
* caller's thread was interrupted, or the call was cancelled), DEADLINE is retried exactly
* once (the partition leader may have moved after the failed RPC invalidated the partition
* cache; a second deadline in a row would only wait the full deadline again on the same
* stalled store), everything else (transport errors, NOT_LEADER, store replacement) is
* retried up to NODE_MAX_RETRYING_TIMES as before. For a parallel commit the failures of
* the other partitions arrive as suppressed exceptions and are classified too; the most
* severe class wins.
*/
enum Failure {
RETRYABLE, DEADLINE, FATAL
}

static Failure classify(Throwable t) {
return classify(t, Collections.newSetFromMap(new IdentityHashMap<>()));
}

private static Failure classify(Throwable t, Set<Throwable> seen) {
Failure worst = Failure.RETRYABLE;
Throwable c = t;
while (c != null && seen.add(c)) {
if (c instanceof InterruptedException) {
return Failure.FATAL;
}
if (c instanceof StatusRuntimeException) {
Status.Code code = ((StatusRuntimeException) c).getStatus().getCode();
if (code == Status.Code.CANCELLED) {
return Failure.FATAL;
}
if (code == Status.Code.DEADLINE_EXCEEDED) {
worst = Failure.DEADLINE;
}
}
for (Throwable suppressed : c.getSuppressed()) {
Failure f = classify(suppressed, seen);
if (f == Failure.FATAL) {
return f;
}
if (f.compareTo(worst) > 0) {
worst = f;
}
}
c = c.getCause();
}
return worst;
}

private boolean isValid(Object obj) {
if (obj == null) {
return false;
Expand Down
Loading