Skip to content

Enforce Future or void return declaration for each asynchronously executed method (e.g. with class-level @Async) #27734

Closed
@djechelon

Description

@djechelon

Callable<Object> task = () -> {
try {
Object result = invocation.proceed();
if (result instanceof Future) {
return ((Future<?>) result).get();
}
}
catch (ExecutionException ex) {
handleError(ex.getCause(), userDeclaredMethod, invocation.getArguments());
}
catch (Throwable ex) {
handleError(ex, userDeclaredMethod, invocation.getArguments());
}
return null;
};

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

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)type: enhancementA general enhancement

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions