Skip to content

fix: make DgraphAsyncClient non-blocking to avoid ForkJoinPool.commonPool starvation - #294

Open
mlwelles wants to merge 14 commits into
mainfrom
fix/async-client-non-blocking-retries
Open

fix: make DgraphAsyncClient non-blocking to avoid ForkJoinPool.commonPool starvation#294
mlwelles wants to merge 14 commits into
mainfrom
fix/async-client-non-blocking-retries

Conversation

@mlwelles

@mlwelles mlwelles commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Description

DgraphAsyncClient ran its async work on ForkJoinPool.commonPool() and blocked a pool thread for the full duration of every gRPC call: CompletableFutures.runWithRetries wrapped each call in supplyAsync(...) 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, other CompletableFuture chains). Fixes #293.

This PR:

  • Rewrites runWithRetries to compose on the StreamObserverBridge future (handleAsync plus a single JWT-expiry retry via thenComposeAsync) instead of blocking. No thread is parked for the round trip; the supplied Executor becomes a callback executor. The default commonPool() is now safe because the callbacks never block.
  • Completes every path on that callback executor, including the JWT-retry path. Its gRPC future completes on a channel thread, so without an explicit hop back the returned future would complete there too, and any continuation the caller chained without an Async variant would run on gRPC's event loop — where a blocking callback stalls every RPC on the channel.
  • Preserves the observed exception exactly: every failure completes the returned future with CompletionException(DgraphException), so .join() in the synchronous DgraphClient still surfaces the typed DgraphException.
  • Fixes an adjacent race in the login/refresh path: the jwt field was written inside a thenAccept callback 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.
  • Adds 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. supplyAsync previously 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 Executor constructor parameter's meaning is a compatible superset (callback executor), documented in the constructor Javadoc.

Checklist

  • Code compiles correctly and linting passes locally
  • For all code changes, an entry added to the CHANGELOG.md file describing and linking to
    this PR
  • Tests added for new functionality, or regression tests for bug fixes added as applicable

mlwelles added 3 commits July 23, 2026 08:59
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
@mlwelles
mlwelles requested a review from a team as a code owner July 24, 2026 01:55
@mlwelles

Copy link
Copy Markdown
Contributor Author

Note on the red dgraph4j-tests check: the only failing test, AlterConvenienceTest.testDropType, is a pre-existing failure unrelated to this change.

  • It fails identically on main. The scheduled ci-dgraph4j-tests run on 2026-07-23 (run 29994485774) failed on the same test with the same assertion (Type Person should exist in schema before drop ... expected [true] but found [false]) and the same schema dump — the name predicate is applied but the Person type is absent from schema { types }.
  • Test-count corroboration: main ran 146 tests (1 failed); this PR ran 153 (1 failed). The 7 extra are the new CompletableFuturesTest unit tests added here, all passing. The same single test is red in both.
  • Root cause is server-side. This job builds Dgraph from latest source on every run, and the type-definition visibility behavior changed. This PR only changes client-side threading, not schema requests.

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

mlwelles commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

All checks are green on aaa1269, which adds one fix on top of the original change: the JWT-retry path ended in a synchronous handle(...), so the returned future completed on a gRPC channel thread instead of the callback executor. handleAsync(..., executor) now closes that gap, and retryPathCompletesOnCallbackExecutor covers it. I confirmed the new test fails with the old handle(...) restored.

Correcting my earlier note: I wrote that AlterConvenienceTest.testDropType "fails identically on main," which reads as always. It does not. The test is intermittent on main:

Date (2026) Scheduled ci-dgraph4j-tests on main
Aug 1 fail (testDropType)
Aug 2 pass
Aug 3 pass
Aug 4 pass
Aug 5 fail (testDropType)
Aug 6 fail (testDropType)

The job builds Dgraph from source on every run, so the outcome tracks the server's Person type visibility that day rather than anything in this client. This PR's run passed, so nothing here depends on it. The flake deserves its own issue.

…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.
Comment thread src/main/java/io/dgraph/CompletableFutures.java Outdated
Comment thread src/main/java/io/dgraph/DgraphAsyncClient.java Outdated
Comment thread src/main/java/io/dgraph/AsyncTransaction.java
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

DgraphAsyncClient blocks ForkJoinPool.commonPool threads and can starve the JVM common pool

2 participants