I am looking for a generic solution for cleaning up resources when working with asynchronous code in the form of CompletableFuture. It should offer the same guarantees as try-finally and try-with-resources for synchronous code. I've asked the question on Code Review, and I got an interesting answer, but it was closed since it better fits here, so here we go.
Suppose I have a function that takes a FileInputStream, performs some operation using the file data in the background, and returns a CompletableFuture<Void that will be completed when the background operation finishes:
CompletableFuture<Void> asyncFileOperation(FileInputStream in) {
var cf = new CompletableFuture<Void>();
// ...
return cf;
}
FileInputStream is a resource that has to be closed when not needed anymore. With synchronous code, we would use finally or try-with-resources. But with CompletableFuture, it's a bit more complicated. I can think of four ways, with pros and cons:
1. Simply using whenComplete:
FileInputStream in = openFile("data.txt");
asyncFileOperation(in)
.whenComplete((r, x) -> in.close());
This is the "obvious" solution, as it is the direct translation from finally to the CompletableFuture world. It effectively closes the stream when asyncFileOperation completes, regardless whether it succeeds or fails. Except, when asyncFileOperation throws a synchronous RuntimeException before even returning the CompletableFuture, whenComplete will not be called, so neither will close.
We could write this off as "never happens", but unexpected exceptions are the very reason we use finally and try-with-resources in the synchronous case. Is there any reason to believe this could be different for methods that return CompletableFuture?
2. Calling asyncFileOperation inside thenCompose:
CompletableFuture.completedFuture(openFile("data.txt"))
.thenCompose(this::asyncFileOperation)
.whenComplete((r, x) -> in.close());
This solves the problem of solution 1 by executing asyncFileOperation in the context of an existing CompletableFuture, so any exception gets caught and whenComplete is always executed. The problem with this is that when an unexpected exception occurs, it will be swallowed silently (unless we excplicitely handle it). It will not bubble up the stack (unless we return the CompletableFuture and a handler further up the stack handles it), and it will never be passed to an UncaughtExceptionHandler. That is particularly bad for unexpected exceptions ones such as NullPointerException.
3. Using both try-catch and whenComplete:
FileInputStream in = openFile("data.txt");
try {
asyncFileOperation(in)
.whenComplete((r, x) -> in.close());
} catch (RuntimeException exc) {
in.close();
throw exc;
}
This solves all of the above problems, and doesn't really introduce new ones, except that it's ugly and repetitive. I've seen this approach in the JDK implementation of HttpClient.sendAsync:
it calls unreference() both in a lambda passed to whenComplete and in a catch clause.
4. Doing it inside asyncFileOperation:
CompletableFuture<Void> asyncFileOperation(FileInputStream in) {
var cf = new CompletableFuture<Void>();
// ...
return cf.whenComplete((r, x) -> in.close());
}
asyncFileOperation(openFile("data.txt"));
Here, asyncFileOperation closes the stream itself ones it completes. Depending on the implementation, it might be obvious that there can be no RuntimeException, so we could eliminate some cases. If not, we need to use one of the approaches above to handle this, but we can handle it once and for all rather for every call.
Still, I don't like this because maybe we want to continue to use the stream for some other operations, and its always nice when resources are created and cleaned up in the same place.
So what are your thoughts on these options? Do you have other ones?
I guess the question really boils down to whether whenComplete alone is enough and I'm just being paranoid. If yes, I would stick to solution 1, otherwise it's maybe a question of context and personal taste whether solution 2 or 3 fits better.
Edit
Some comments suggest to avoid the situation altogether by opening and closing the resource synchronously inside asyncFileOperation. I agree that it would be preferable if possible, but what if it isn't? Maybe the caller must read a few lines, and asyncFileOperation reads the rest. Or, maybe asyncFileOperation reads the file in an asynchronous manner, which means we can open and close the stream inside the function, but we must close it asynchronously, so the question is again how to do it (maybe here solution 3 is best, because we can close the resource in a single place, just like HttpClient).
Also, I used FileInputStream just as some concrete example. In our real project, we often show a dialog that returns a CompletableFuture which is completed when the dialog is closed, and we need to do some cleanup work then, like removing UI event listeners, or closing a DB connection that is used throughout the whole async process.
Are scenarios like these always bad? Should I avoid them at all cost? Is that always possible? If so, are there any resources on how to design software in such a manner? If not, I would like to know how the best way to handle such situations in general.