What
CompletableFutures.runWithRetries wraps every query/mutation/commit as CompletableFuture.supplyAsync(() -> ctxCallable.call().get(), executor). The .get() blocks the executor thread for the entire gRPC round-trip, even though the underlying stub call is already async — the response arrives via StreamObserverBridge on a gRPC event-loop thread. So each in-flight request holds one executor thread until it completes.
The default executor is ForkJoinPool.commonPool() (parallelism ≈ availableProcessors() - 1). Net effect: concurrent in-flight transactions are hard-capped at roughly the client's core count, regardless of how many transactions the caller launches — extra work just queues in the pool.
Why it matters
For high-throughput ingest against a multi-core alpha this starves the server: a client on an N-core box keeps at most ~N requests in flight, so a large alpha sees only ~N concurrent mutations and its apply cores sit idle. Turning up ingest parallelism on the client has no effect past the pool size. Today's workarounds (pass a large custom Executor, use multiple channels) mitigate it but burn a blocked thread per in-flight request and require every caller to know the trap — and the ergonomic DgraphClient.ClientOptions.build() path (single channel + default commonPool) is the least concurrent of all.
Proposed fix
Rewrite runWithRetries to compose on the future instead of blocking on it: return the CompletableFuture from ctxCallable.call() directly and attach the JWT-expiry retry via .handle/.thenCompose rather than supplyAsync(() -> ...get()). No thread is held for the round-trip; in-flight concurrency is then bounded only by HTTP/2 streams (and whatever the caller chooses), not by the executor's size.
Preserve: the single JWT-refresh-then-retry-once semantics, gRPC Context propagation (Context.current().wrap(...)), and exception translation.
Acceptance
- No executor thread is blocked for the duration of a request's round-trip.
- Concurrent in-flight request count is not bounded by the executor's parallelism (verify: launch M ≫ pool-size async mutations, observe ~M in flight).
- Existing retry / JWT-refresh and exception-translation behavior unchanged; tests green.
Refs
src/main/java/io/dgraph/CompletableFutures.java — runWithRetries
src/main/java/io/dgraph/DgraphAsyncClient.java — executor defaults to ForkJoinPool.commonPool()
src/main/java/io/dgraph/AsyncTransaction.java — doRequest/commit route through runWithRetries
What
CompletableFutures.runWithRetrieswraps every query/mutation/commit asCompletableFuture.supplyAsync(() -> ctxCallable.call().get(), executor). The.get()blocks the executor thread for the entire gRPC round-trip, even though the underlying stub call is already async — the response arrives viaStreamObserverBridgeon a gRPC event-loop thread. So each in-flight request holds one executor thread until it completes.The default executor is
ForkJoinPool.commonPool()(parallelism ≈availableProcessors() - 1). Net effect: concurrent in-flight transactions are hard-capped at roughly the client's core count, regardless of how many transactions the caller launches — extra work just queues in the pool.Why it matters
For high-throughput ingest against a multi-core alpha this starves the server: a client on an N-core box keeps at most ~N requests in flight, so a large alpha sees only ~N concurrent mutations and its apply cores sit idle. Turning up ingest parallelism on the client has no effect past the pool size. Today's workarounds (pass a large custom
Executor, use multiple channels) mitigate it but burn a blocked thread per in-flight request and require every caller to know the trap — and the ergonomicDgraphClient.ClientOptions.build()path (single channel + default commonPool) is the least concurrent of all.Proposed fix
Rewrite
runWithRetriesto compose on the future instead of blocking on it: return theCompletableFuturefromctxCallable.call()directly and attach the JWT-expiry retry via.handle/.thenComposerather thansupplyAsync(() -> ...get()). No thread is held for the round-trip; in-flight concurrency is then bounded only by HTTP/2 streams (and whatever the caller chooses), not by the executor's size.Preserve: the single JWT-refresh-then-retry-once semantics, gRPC
Contextpropagation (Context.current().wrap(...)), and exception translation.Acceptance
Refs
src/main/java/io/dgraph/CompletableFutures.java—runWithRetriessrc/main/java/io/dgraph/DgraphAsyncClient.java— executor defaults toForkJoinPool.commonPool()src/main/java/io/dgraph/AsyncTransaction.java—doRequest/commitroute throughrunWithRetries