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.
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