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 14 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.
Two sites in attemptAsync completed the returned future only from inside a callback, so a RejectedExecutionException left the caller waiting forever. A bounded ThreadPoolExecutor with the default AbortPolicy rejects under load, which is the traffic profile this branch is about. The callback site now uses handleAsync, which consumes the attempt's outcome, and relays a failure of that stage into the result. The backoff site drops the executor from delayedExecutor: that shape submits from the internal Delayer thread, which swallows the rejection and never completes the future at all. Hopping back with thenComposeAsync keeps the retry on the executor and lets a rejection fail the stage. The timer itself is the same no-op trampoline main already used. runWithRetries also gained a synchronous terminal stage so a rejection surfaces as a DgraphException rather than a raw RejectedExecutionException.
thenAccept is a synchronous stage, so login() and loginIntoNamespace() completed on whatever thread gRPC delivers onNext on. setJwt takes the write lock that every in-flight request contends on for read; on a channel built with directExecutor() that thread is the event loop.
…on pool ForkJoinPool.commonPool() is a JVM-wide singleton sized availableProcessors() - 1, so a two-vCPU container gets one thread. It cannot be tuned per library, its queue is unbounded, and its unnamed daemon threads hide the contention in a thread dump. None of that suits I/O continuations. The constructor keeps the common pool rather than owning an executor, since an owned executor would make the client responsible for shutting it down and leak threads for callers that never do. Removal waits for a major release, so this is a compile warning and nothing more.
AsyncTransaction.close() logs a failed abort while Transaction.close() throws. Transaction.close() calls Transaction.discard(), which joins on asyncTransaction.discard() directly, so it never routes through AsyncTransaction.close(). Both javadocs now state the difference and the reason rather than leaving two AutoCloseables silently disagreeing; reconciling them changes long-standing synchronous behavior and belongs in its own change. The changelog moves the close() entry to Changed, since it alters observable behavior, and drops the claim that futures complete on the configured executor "on every path" for what the code guarantees.
Normalizing the exception wrapping in CompletableFutures.attemptAsync changed what async callbacks observe: retry exhaustion used to deliver a CompletionException wrapping the DgraphException, while a first-attempt failure delivered the exception directly. Both now deliver it directly. That is observable to callers who unwrap by hand, so it belongs under Changed rather than passing silently.
retryBackoffAndCompletionRunOnSuppliedExecutor asserted that a non-async thenApply on attemptAsync's result ran on "retry-executor", after blocking on that same result. A thread waking from CompletableFuture.get() calls postComplete() itself, so the test thread could drain the result's dependent stack and run the thenApply, reporting "Test worker". Stressing the old test reproduced the CI failure 68 times in 2000 runs. Keep the property, replace the mechanism. The test now parks the sole executor thread, completes the retry future from a thread named grpc-thread, and requires result to stay pending until the blocker is released. Only a task queued behind that blocker can complete result, so no timing assumption remains. The backoff half keeps its thread-name assertion: thenComposeAsync always dispatches through the supplied executor, so that one never raced. Mutation-checked both halves. Replacing handleAsync(cb, executor) with handle(cb) fails the occupancy assertion; replacing thenComposeAsync(fn, executor) with thenCompose(fn) fails the backoff assertion. The fixed test passed 2000 in-JVM runs and 50 Gradle runs. Test-only; src/main is unchanged.
retryPathCompletesOnCallbackExecutor asserted a thread name captured by a non-async thenApply on the future runWithRetries returns. That holds only while the stage is registered before the source completes and no thread blocks on the source first. Nothing in the test recorded either condition. Both hold today, and stressing the test confirmed it: 0 failures in 2000 runs. The hazard is the next edit. Blocking on the source and then attaching the capture, result.get() followed by result.thenApply(...), fails 2000 of 2000 runs reporting "expected [callback-executor] but found [Test worker]", the symptom that took the sibling test down in CI. Interleaving a bare result.get() before the capture, the shape that made the sibling flake, never reproduced here: 0 failures in 2000 runs, and 0 again with the waiter and the completer released together to widen the race. Keep the property, replace the mechanism, as the sibling test already does. The test now parks the sole executor thread on a latch, completes the retry future from a thread named grpc-thread, and requires result to stay pending until the blocker is released. Only a task queued behind that blocker can complete result, which excludes the common pool and the gRPC thread without naming a thread. Mutation-checked: replacing the retry path's handleAsync(cb, executor) with handle(cb) fails the occupancy assertion in 2000 of 2000 runs. Reverted, the test passed 10000 in-JVM runs. Test-only; src/main is unchanged, and the suite's 19 cluster-dependent failures are identical before and after.
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