Is there a way of trying to wait for a CompletableFuture
a certain amount of time before giving a different result back without cancelling the future after timing out?
I have a service (let's call it expensiveService
) that runs off to do its own thing. It returns a result:
enum Result {
COMPLETED,
PROCESSING,
FAILED
}
I'm willing to [block and] wait for it for a short amount of time (let's say 2 s). If it doesn't finish, I want to return a different result, but I want the service to carry on doing its own thing. It would be the client's job to then inquire as to whether the service is finished or not (e.g. through websockets or whatever).
I.e. we have the following cases:
expensiveService.processAndGet()
takes 1 s and completes its future. It returnsCOMPLETED
.expensiveService.processAndGet()
fails after 1 s. It returnsFAILED
.expensiveService.processAndGet()
takes 5 s and completes its future. It returnsPROCESSING
. If we ask another service for the result, we getCOMPLETED
.expensiveService.processAndGet()
fails after 5 s. It returnsPROCESSING
. If we ask another service for the result, we getFAILED
.
In this specific case, we actually need to fetch the current result object anyway on a timeout, resulting in the following additional edge-case. This causes some issues with the solutions suggested below:
expensiveService.processAndGet()
takes 2.01 s and completes its future. It returns eitherPROCESSING
orCOMPLETED
.
I'm also using Vavr and am open to suggestions using Vavr's Future
.
We have created three possible solutions which all have their own positives and negatives:
#1 Wait for another Future
CompletableFuture<Result> f = expensiveService.processAndGet();
return f.applyToEither(Future.of(() -> {
Thread.sleep(2000);
return null;
}).map(v -> resultService.get(processId)).toCompletableFuture(),
Function.identity());
Problems
- The second
resultService
is always called. - We take up the entire Thread for 2 s.
#1a Wait for another Future that checks the first Future
CompletableFuture<Result> f = expensiveService.processAndGet();
return f.applyToEither(Future.of(() -> {
int attempts = 0;
int timeout = 20;
while (!f.isDone() && attempts * timeout < 2000) {
Thread.sleep(timeout);
attempts++;
}
return null;
}).map(v -> resultService.get(processId)).toCompletableFuture(),
Function.identity());
Problems
- The second
resultService
is still always called. - We need to pass the first Future to the second, which isn't so clean.
#2 Object.notify
Object monitor = new Object();
CompletableFuture<Upload> process = expensiveService.processAndGet();
synchronized (monitor) {
process.whenComplete((r, e) -> {
synchronized (monitor) {
monitor.notifyAll();
}
});
try {
int attempts = 0;
int timeout = 20;
while (!process.isDone() && attempts * timeout < 2000) {
monitor.wait(timeout);
attempts++;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (process.isDone()) {
return process.toCompletableFuture();
} else {
return CompletableFuture.completedFuture(resultService.get(processId));
}
Problems
- Complex code (potential for bugs, not as readable).
#3 Vavr's Future.await
return Future.of(() -> expensiveService.processAndGet()
.await(2, TimeUnit.SECONDS)
.recoverWith(e -> {
if (e instanceof TimeoutException) {
return Future.successful(resultService.get(processId));
} else {
return Future.failed(e);
}
})
.toCompletableFuture();
Problems
- Needs a Future in a Future to avoid
await
cancelling the inner Future. - Moving the first Future into a second breaks [legacy] code that relies on
ThreadLocal
s. recoverWith
and catching theTimeoutException
isn't that elegant.
#4 CompletableFuture.orTimeout
return expensiveService.processAndGet()
.orTimeout(2, TimeUnit.SECONDS)
.<CompletableFuture<Upload>>handle((u, e) -> {
if (u != null) {
return CompletableFuture.completedFuture(u);
} else if (e instanceof TimeoutException) {
return CompletableFuture.completedFuture(resultService.get(processId));
} else {
return CompletableFuture.failedFuture(e);
}
})
.thenCompose(Function.identity());
Problems
- Although in my case the
processAndGet
future is not cancelled, according to docs, it should be. - The exception handling is not nice.
#5 CompletableFuture.completeOnTimeout
return expensiveService.processAndGet()
.completeOnTimeout(null, 2, TimeUnit.SECONDS)
.thenApply(u -> {
if (u == null) {
return resultService.get(processId);
} else {
return u;
}
});
Problems
- Although in my case the
processAndGet
future is not completed, according to docs, it should be. - What if
processAndGet
wanted to returnnull
as a different state?
All of these solutions have disadvantages and require extra code but this feels like something that should be supported either by CompletableFuture
or Vavr's Future
out-of-the-box. Is there a better way to do this?