Description
I have found an odd behaviour working with @Async
-annotated classes in Spring. Please note that there is a fundamental error in my code. Unfortunately, this post has to be long and detailed.
Let's say I have already made a synchronous REST API generated by Swagger generator. Following code omits all documentation-level annotations
public interface TaxonomiesApi {
ResponseEntity<GenericTaxonomyItem> disableItem(Integer idTaxonomyType, String idTaxonomy, String appSource);
}
This API is easily implemented via RestTemplate
, but I won't discuss the inner details.
Now, suppose I want to provide an async version to developers consuming the API. What I have done is to create another interface with some search&replace-fu 🥋🥋
@Async
public interface TaxonomiesApiAsync extends TaxonomyApi {
default CompletableFuture<ResponseEntity<GenericTaxonomyItem>> disableItemAsync(Integer idTaxonomyType, String idTaxonomy, String appSource) {
try {
return completedFuture(this.disableItem(idTaxonomyType, idTaxonomy, appSource));
} catch (Exception ex) {
return failedFuture(ex);
}
}
}
With the search&replace, I basically created an async-ish version of every method that should be backed by Spring's @Async
annotation. My original idea was that synchronous methods can be invoked as they are, but if you instantiate TaxonomiesApiAsync
you also have access to the async version.
I have discovered I made a fundamental mistake by applying the @Async
annotation at interface level when the class contains both sync and async methods. I found that synchronous disableItem
was performed in the same @Async
context. Accoding to design (correctly), Spring found the @Async
annotation at interface level so every method, including inherited ones, was invoked asynchronously.
But the method always returned null. By debugging and looking at the code, I found that Spring tries to resolve the return value of the invoked method only if it's a Future
. What if the returned value is a Present object?
That means that if the returned value is not a Future<ResponseEntity<GenericTaxonomyItem>>
but rather just a ResponseEntity<GenericTaxonomyItem>
Spring neither throws an exception nor returns that value directly.
Example of working calling code (invoking a different method)
protected CompletableFuture<Set<TaxonomyLegalEntityDTO>> importTaxonomyLegalEntities(int userId) {
TaxonomySearchParameters searchParameters = new TaxonomySearchParameters();
searchParameters.setIdTaxonomyType(amlcProperties.getTaxonomies().getTaxonomyLegalEntitiesId());
searchParameters.setLogicalState(1);
return taxonomiesApiAsync.getAllTaxonomyItemsAsync(searchParameters)
.thenApply(ResponseEntity::getBody)
.thenApply(taxonomyLegalEntityMasterDbMapping::toLegalEntity) // Costruisco i DTO che voglio utilizzare
.whenComplete(traceLoggerConsumer("Legal entity"))
.thenApply(dtos -> taxonomyLegalEntityManager.mergeFromMasterDb(dtos, userId))
.whenComplete((ignored, ex) -> {
if (ex != null)
log.error("Error importing legal entities: " + ex.getMessage(), ex);
})
.thenApply(TaxonomyMasterDbMergeDTO::getSnapshot);
}
Example of non-working code; the result of the CompletableFuture is always null.
In this code, I decided not to use the executor embedded in the API service, but rather the executor injected in the consuming service. So I ran a sync method in an executor, expecting the same result.
protected CompletableFuture<Set<TaxonomyLegalEntityDTO>> importTaxonomyLegalEntities(int userId) {
TaxonomySearchParameters searchParameters = new TaxonomySearchParameters();
searchParameters.setIdTaxonomyType(amlcProperties.getTaxonomies().getTaxonomyLegalEntitiesId());
searchParameters.setLogicalState(1);
return CompletableFuture.supplyAsync(() -> taxonomiesApi.getAllTaxonomyItems(searchParameters), taxonomyBatchImportServiceExecutor)
.thenApply(ResponseEntity::getBody)
.thenApply(taxonomyLegalEntityMasterDbMapping::toLegalEntity)
.whenComplete(traceLoggerConsumer("Legal entity"))
.thenApplyAsync(dtos -> taxonomyLegalEntityManager.mergeFromMasterDb(dtos, userId))
.whenComplete((ignored, ex) -> {
if (ex != null)
log.error("Error importing legal entities: " + ex.getMessage(), ex);
})
.thenApply(TaxonomyMasterDbMergeDTO::getSnapshot);
}
Since I spent one hour debugging that problem, I decided to spend more of my after-work time to document the issue here.
Proposed fix
In the code I linked, if the instanceof
check fails the returned value is simply null. I don't yet understand the implications, but what about not unwrapping the value from Future if that's not a future? I mean return result