fix: make DgraphAsyncClient non-blocking to avoid ForkJoinPool.commonPool starvation - #294
fix: make DgraphAsyncClient non-blocking to avoid ForkJoinPool.commonPool starvation#294mlwelles wants to merge 7 commits into
Conversation
runWithRetries wrapped each gRPC call in supplyAsync and then blocked the executor thread on .get() for the whole round trip, which starves ForkJoinPool.commonPool() under load. Compose on the stub future instead so no thread is parked; the executor becomes a callback executor. Refs #293
The jwt field was written inside a thenAccept callback after the write lock was released, leaving the write unguarded and unpublished across threads. Move the write into a lock-held setter and read the refresh token under the read lock. Refs #293
Document in the DgraphAsyncClient constructors that the Executor is a callback executor and that the commonPool default is safe because callbacks never block. Also dedupe a log message in setJwt and cover the null-future path in runWithRetries. Refs #293
|
Note on the red
The other 152 integration tests, CodeQL, and Trunk are green. This looks worth tracking separately rather than blocking this PR. |
The JWT-refresh retry path ended in a synchronous handle() stage, so the returned future completed on a gRPC channel thread rather than the client's callback executor. A caller chaining a continuation without an explicit Async variant therefore ran it on gRPC's event loop, where a blocking callback stalls every RPC on that channel. Switch that stage to handleAsync(..., executor) so the success, translated failure, and JWT-retry paths all complete on the executor, and add a test that fails without the change. Fold the repeated failed-future construction into CompletableFuture.failedFuture, name the operation in the null-future message, correct retryLogin's message now that it also covers an absent jwt, and trim comments that narrated the diff rather than the code.
|
All checks are green on Correcting my earlier note: I wrote that
The job builds Dgraph from source on every run, so the outcome tracks the server's |
…hrowing Two blocking-and-threading defects adjacent to the non-blocking rewrite, both pre-existing. attemptAsync scheduled its backoff with delayedExecutor(delay, unit), whose no-executor overload targets the common pool. withRetry therefore ran every retry and completed its future on the common pool no matter which executor the client was constructed with. Thread the executor through attemptAsync and complete via whenCompleteAsync, so withRetry honors the same callback-executor contract as the rest of the client. AsyncTransaction.close() propagated a failed abort. Since discard is documented as best-effort and the server reaps abandoned transactions, a close that throws only masks the result of the work it wrapped -- the classic cleanup-in-finally hazard. Log it instead, and document that close blocks when the transaction has uncommitted mutations. Add server-free coverage for attemptAsync, which had none. Both assertions fail against the old code: the backoff assertion reports the common-pool worker, and the completion assertion reports the foreign completing thread.
|
|
||
| op.execute(txn) | ||
| .whenComplete( | ||
| .whenCompleteAsync( |
There was a problem hiding this comment.
result is a bare future that only gets completed from inside this callback, so if executor rejects the task, nothing else ever completes it and the caller's withRetry future hangs for good.
Same hazard at line 161: delayedExecutor(delayMs, MILLISECONDS, executor) submits through a TaskSubmitter running on the internal Delayer thread, and a RejectedExecutionException thrown there goes nowhere, so the backoff future never completes either.
Before this PR neither site could reject — whenComplete was synchronous and delayedExecutor targeted the common pool. I confirmed both against a shut-down ThreadPoolExecutor; result never completes in either case.
This isn't only a shutdown-ordering edge case. A ThreadPoolExecutor with a bounded queue and the default AbortPolicy rejects under load, which is exactly the traffic profile #293 is about, and a silent hang is a worse failure mode than the starvation we're fixing.
Relaying the stage's own failure covers the whenCompleteAsync half:
op.execute(txn)
.whenCompleteAsync((value, throwable) -> { ... }, executor)
.whenComplete((ignored, t) -> {
if (t != null) result.completeExceptionally(t); // no-op if already completed
});The backoff chain at 161 needs the same rescue on runAsync(() -> {}, delayed).
Related but much smaller: a rejection inside runWithRetries' handleAsync completes the returned future with a raw RejectedExecutionException instead of a translated DgraphException, so the "every failure is CompletionException(DgraphException)" invariant has that one hole. That degrades to an exception rather than a hang, so I don't feel strongly about it.
|
|
||
| StreamObserverBridge<DgraphProto.Response> bridge = new StreamObserverBridge<>(); | ||
| client.login(loginRequest, bridge); | ||
| return bridge.getDelegate().thenAccept(response -> setJwt(response, true)); |
There was a problem hiding this comment.
thenAccept is a synchronous stage, so this future completes on whatever thread gRPC delivers onNext on rather than on the callback executor. Same at line 143 in retryLogin(). That undercuts the CHANGELOG entry a bit — login() and loginIntoNamespace() are public and return futures, so "every path" isn't quite true yet.
The second-order effect is the one I care about: setJwt takes the write lock, so JWT parsing plus a lock that every in-flight request contends on for read now run on a gRPC callback thread. On a channel built with directExecutor() that thread is the event loop — the same hazard the JWT-retry hop was added to avoid.
thenAcceptAsync(response -> setJwt(response, true), executor) here and the equivalent at 143 would line these up with the rest of the client.
| try { | ||
| discard().join(); | ||
| } catch (CompletionException | CancellationException e) { | ||
| LOG.warn("discarding the transaction during close failed", e); | ||
| } |
There was a problem hiding this comment.
Worth reconciling with Transaction.close(), which still throws: it goes through Transaction.discard() → Exceptions.withExceptionUnwrapped(... join()), and because it calls asyncTransaction.discard() rather than close(), this change doesn't reach it. Two AutoCloseables in the same library with opposite behavior on the same failure. Either give Transaction.close() the same treatment or say why they differ.
Also, for anyone relying on a close-time failure today this is a behavior change, so I'd move that CHANGELOG line under Changed instead of Fixed.
Description
DgraphAsyncClientran its async work onForkJoinPool.commonPool()and blocked a pool thread for the full duration of every gRPC call:CompletableFutures.runWithRetrieswrapped each call insupplyAsync(...)and then called a blocking.get()on the gRPC future. Under sustained or slow traffic this exhausted the JVM-wide common pool and could hang unrelated work (parallel streams, otherCompletableFuturechains). Fixes #293.This PR:
runWithRetriesto compose on theStreamObserverBridgefuture (handleAsyncplus a single JWT-expiry retry viathenComposeAsync) instead of blocking. No thread is parked for the round trip; the suppliedExecutorbecomes a callback executor. The defaultcommonPool()is now safe because the callbacks never block.Asyncvariant would run on gRPC's event loop — where a blocking callback stalls every RPC on the channel.CompletionException(DgraphException), so.join()in the synchronousDgraphClientstill surfaces the typedDgraphException.jwtfield was written inside athenAcceptcallback after the write lock was released, leaving the write unguarded and unpublished. The write now happens in a write-lock-held setter, and the refresh token is read under the read lock.CompletableFuturesTest— server-free unit tests, including a regression test that reproduces the common-pool starvation, one that pins the completion thread to the callback executor, plus retry and exception-translation characterization tests.Behavior change to note
The first attempt of each call now runs on the calling thread, so request serialization happens there instead of on the executor.
supplyAsyncpreviously moved it to a pool thread. This removes a thread hop from every call, but callers issuing requests from a latency-sensitive thread should know where that work lands. The constructor Javadoc states it.No public API changes. The
Executorconstructor parameter's meaning is a compatible superset (callback executor), documented in the constructor Javadoc.Checklist
CHANGELOG.mdfile describing and linking tothis PR