Fixes #680.
Problem
BatchExecutor waited for its sub-commands by leaving a try-with-resources block, which calls the default ExecutorService.close(). That default waits in awaitTermination(1L, TimeUnit.DAYS) inside a while (!terminated) loop, so it re-arms indefinitely and cannot be bounded. BatchPolicy.totalTimeout was never consulted on this path, so a sub-command that outlives its timeout holds the calling thread for as long as it runs.
Change
Wait explicitly instead: shutdown(), awaitTermination() for the remaining time, then shutdownNow() and record a client timeout.
BatchExecutor.executebounds the wait bypolicy.totalTimeout.Batch.retryBatchbounds the split-retry wait by what remains of the command'sdeadline.
BatchStatus keeps the first exception, so a genuine sub-command error still takes precedence over the synthetic timeout. A totalTimeout of zero still means no client-side limit, so that wait stays unbounded — this matters for scan and query, which default to zero.
Executor and QueryPartitionExecutor share the pattern but are left alone here: Executor.execute() takes no policy, so bounding it needs a signature change across ~10 call sites in AerospikeClient, and both default totalTimeout to 0. Happy to follow up if you want them covered.
Test
test/src/com/aerospike/client/BatchExecutorTimeoutTest.java, registered in SuiteSync. It needs no server — it drives BatchExecutor directly with sub-commands that stall, so the wait is the only thing under test.
batchStopsWaitingAtTotalTimeout— before this change it returned after 10,035 ms withtotalTimeout = 500and threw nothing; now it throwsAerospikeException.Timeoutpromptly.batchWaitsWhenTotalTimeoutIsZero— guards the documented unbounded behaviour.
Verification
Against Aerospike Community 8.1.2.4 on Temurin JDK 21.0.1:
SuiteSync— 507 run, 0 failures, 0 errors, 37 skippedSuiteAsync— 30 run, 0 failures, 0 errors
Notes
The async batch path already behaves this way: every sub-command carries its own HashedWheelTimeout armed against min(totalDeadline, socketDeadline), so a listener is always notified on time. This brings sync in line with it.
StructuredTaskScope.joinUntil(Instant) would be the natural fit, but it is still @PreviewFeature in both JDK 21 and 25 and the API changed shape between them.
The Java 8 line is unaffected — no virtual threads on this path — so I have not prepared a stage-jdk8 port. Let me know if you would still like one for symmetry.
This change was prepared with AI assistance.
🤖 Generated with Claude Code
@mirzakaracic would you be able to check this