When configuring Blocking Execution and using a blocking endpoint, the request does not get properly offloaded when request input is available.

Versions: * Spring Boot 3.2.3 * Spring Framework: 6.1.4 * Java: 21

Sample Code

Here is a sample controller with a GET and a POST.

@RestController
@RequestMapping("/api")
public class DemoController {
    private static final Logger LOGGER = LoggerFactory.getLogger(DemoController.class);


    @GetMapping(value = "/get", produces = TEXT_PLAIN_VALUE)
    public ResponseEntity<String> methodBlockingWithRequestBody() {
        return testSafeBlockingThread();
    }

    @PostMapping(value = "/post", consumes = APPLICATION_JSON_VALUE, produces = TEXT_PLAIN_VALUE)
    public ResponseEntity<String> methodBlockingWithRequestBody(@RequestBody final Map<String, String> data) {
        return testSafeBlockingThread();
    }


    private ResponseEntity<String> testSafeBlockingThread() {
        final String threadName = Thread.currentThread().getName();

        if (Schedulers.isInNonBlockingThread()) {
            LOGGER.error("Non-blocking thread:  {}", threadName);

            return ResponseEntity.internalServerError().body("Non-blocking thread:  " + threadName);
        }

        LOGGER.info("Blocking-safe thread:  {}", threadName);
        return ResponseEntity.ok("Blocking-safe thread:  " + threadName);
    }
}

If you make a call to GET /api/get, the response will be 200 with a thread name similar to task-1. If you make a call to POST /api/post with any JSON request body such at {"foo":"bar"} the response will be a 500 with a thread name similar to reactor-http-nio-5.

Expected Results

I would expect both blocking endpoints to be running on a blocking-safe thread.

Notes

I've attached a gzip of a simple application generated from start.spring.io which registers a WebFluxConfigurer to configure a BlockingExecutionConfigurer and has the above controller:

Comment From: blake-bauman

We do have a bit of a hacky workaround which seems to work functionally so developers can continue, but we hope to not have to go into production with this. The workaround is to create a RequestMappingHandlerAdapter returned by WebFluxRegistrations. Pseudo-code:

            if (shouldOffload(handlerMethod)) {
                final MethodParameter[] methodParameters = handlerMethod.getMethodParameters();
                for (int i = 0; i < methodParameters.length; i++) {
                    final HandlerMethodArgumentResolver resolver = findArgRequiringOffloading(methodParameters[i]);
                    if (resolver != null) {
                        // Wrap any method parameters that must be resolved in a blocking manner
                        methodParameters[i] = new BlockingMethodParameter(methodParameters[i], ...);
                    }
                }
            }

Then create a HandlerMethodArgumentResolver which looks for any arg of type BlockingMethodParameter. Pseudo-code:

        @Override
        public boolean supportsParameter(final MethodParameter parameter) {
            return parameter instanceof BlockingMethodParameter;
        }

        @Override
        public Mono<Object> resolveArgument(final MethodParameter param, final BindingContext bindingContext, final ServerWebExchange exchange) {
            if (param instanceof BlockingMethodParameter) {
                return actualArgumentResolver.resolveArgument(...)
                                             .publishOn(offloadScheduler);
            }
        }

Comment From: simonbasle

Indeed, the resolving of arguments causes a thread hop to the netty thread because the Map is decoded from the request body which is emitted by reactor-netty on the netty thread. When arguments get zipped, this causes behavior similar in spirit to a publishOn(nettyThread), which modifies the thread on which the method invocation will take place despite framework having correctly subscribed to the whole thing on the task-x thread...

And testament to that, your workaround uses an explicit publishOn(offloadScheduler) to hop back to the desired thread right before the controller method invocation 👍

We should find a way to do something equivalent directly in InvocableHandlerMethod, somehow hinting to that class that there's a scheduler to use when calling it from RequestMappingHandlerAdapter.

Comment From: blake-bauman

Sounds good to me. I'm happy to try out a snapshot when one becomes available.

Comment From: snicoll

@blake-bauman, a new Spring Framework 6.1.6-SNAPSHOT should be available shortly. Let us know if you find a problem with the fix. Thanks!

Comment From: blake-bauman

Looks good! I'm getting this now with @RequestBody and @RequestPart now:

Blocking-safe thread:  task-2