matrei commented on PR #16230:
URL: https://github.com/apache/grails-core/pull/16230#issuecomment-5697390108

   ## Review
   
   **Head:** `8dd2b0a1dd` (`feature/modernize-grails-async-8.0.x`) · **Base:** 
`8.0.x` @ `a5758d657f` · merge-base is clean (branch is a fast-forward 
candidate; `origin/8.0.x` has since moved 91 commits, none touching async or 
URL mappings).
   
   **What I ran locally**
   
   | Check | Result |
   |---|---|
   | `:grails-async-core:test` (fresh, `--no-build-cache`) | 54 tests, 0 
failures, 1 skipped |
   | `:grails-async:test` (fresh) | 18 tests, 0 failures |
   | `:grails-web-url-mappings:test` (`--rerun`) | 259 tests, 0 failures |
   | `:grails-async-core:codeStyle :grails-async:codeStyle 
:grails-web-url-mappings:codeStyle` | pass |
   | Throwaway probe specs (deleted afterwards) for the findings below | see 
"Probe" notes per finding |
   | Boot 4.1.1 bytecode (`OnBeanCondition`, `TaskExecutorConfigurations`, 
`WebMvcAutoConfiguration`) | inspected with `javap` |
   
   **Verdict: request changes.** The direction is right and the Spring MVC 
`DeferredResult` integration is a clear improvement over the hand-rolled 
`AsyncContext` handling, but the PR currently regresses four things that ship 
enabled in every generated Grails app: the events bus silently becomes 
synchronous, exceptions with a cause are misreported, the promise factory bean 
fails to resolve when a second `AsyncTaskExecutor` exists (e.g. 
`@EnableScheduling`), and the fallback executor is single-threaded.
   
   ---
   
   ### High
   
   **H1. Every Grails app's `EventBus` silently becomes synchronous.**
   
`grails-events/core/src/main/groovy/org/grails/events/bus/spring/EventBusFactoryBean.groovy:73`
 only reuses the promise factory when it `instanceof ExecutorService`. 
`CachedThreadPoolPromiseFactory` was one (via `ExecutorPromiseFactory`); 
`CompletableFuturePromiseFactory` is not. The factory bean therefore falls 
through to `EventBusBuilder.createDefaultEventBus()`, which logs `No event bus 
implementations found on classpath, using synchronous implementation.` and 
builds `ExecutorEventBus(new SyncTaskExecutor())`. `grails-events` is added to 
every forge-generated app (`grails-forge-core 
.../feature/grails/GrailsBase.java:102`), so `sendEvent`/`@Subscriber` 
listeners now run on the caller's thread with a spurious WARN at startup. The 
updated `grails-doc/src/en/guide/async/events.adoc:32` claims the bus "executes 
work on Spring Boot's `applicationTaskExecutor`", which is now false.
   *Probe:* `EventBusFactoryBean` with a non-`ExecutorService` 
`grailsPromiseFactory` singleton → `ExecutorEventBus.executor` is 
`SyncTaskExecutor`.
   *Fix:* `ExecutorEventBus` already accepts a plain `Executor`. Have 
`EventBusFactoryBean` use `CompletableFuturePromiseFactory.executor` (or 
introduce a small `ExecutorAware`-style interface on the factory) so the bus 
shares the Boot executor as the docs say. Add a test in `grails-events` for it.
   
   **H2. `unwrap` strips one cause level from *any* exception, so `onError` and 
the error page see the wrong exception.**
   `CompletableFuturePromise.groovy:144` (`failure.cause ?: failure`) is 
applied in `onError` (line 75) to the raw exception the promise itself failed 
with, not to a `CompletionException` wrapper. For `createPromise { throw new 
IllegalStateException('outer', new IOException('inner')) }`, the `onError` 
callback receives the `IOException`. `AsyncActionResultTransformer.groovy:72` 
then applies its own `unwrap` (line 79) on top, so the exception handed to 
`DeferredResult.setErrorResult` and ultimately to `GrailsExceptionResolver` / 
UrlMappings exception mappings is the cause, not the thrown exception. Any 
exception mapping keyed on the thrown type (`"500"(controller: 'errors', 
exception: MyServiceException)`) will stop matching when that exception has a 
cause.
   *Probe:* both `Promise.onError` and `WebAsyncManager.concurrentResult` 
observed `java.io.IOException: inner`.
   *Fix:* only unwrap `CompletionException`/`ExecutionException` (and only when 
`cause != null`), and drop the second unwrap in the transformer. Add tests with 
a caused exception to `CompletableFuturePromiseFactorySpec` and 
`AsyncActionResultTransformerSpec`.
   
   **H3. `context.bean(AsyncTaskExecutor)` fails with 
`NoUniqueBeanDefinitionException` whenever a second `AsyncTaskExecutor` 
exists.**
   `ControllersAsyncGrailsPlugin.groovy:72` resolves by type. 
`ThreadPoolTaskScheduler` implements `AsyncTaskExecutor`, so any app with 
`@EnableScheduling` (Boot auto-configures `taskScheduler`) or with its own 
second executor bean fails on context startup with `expected single matching 
bean but found 3: grailsPromiseExecutor,applicationTaskExecutor,taskScheduler`. 
The fallback flag does not help here because there are two non-fallback 
candidates. Boot itself avoids this: 
`WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter.configureAsyncSupport` 
looks up `applicationTaskExecutor` **by name** and does nothing otherwise.
   *Probe:* registrar + `applicationTaskExecutor` + an initialized 
`ThreadPoolTaskScheduler` singleton → `BeanCreationException` wrapping the 
above.
   *Fix:* 
`context.beanFactory.containsBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)`
 → use it, else the `grailsPromiseExecutor` bean by name. Cover it with a test 
that registers two executors.
   
   **H4. The fallback executor is single-threaded.**
   `ControllersAsyncGrailsPlugin.groovy:62` uses a default 
`ThreadPoolTaskExecutor` (core 1, max `Integer.MAX_VALUE`, queue 
`Integer.MAX_VALUE`). With an unbounded queue the pool never grows past the 
core size, so every promise in a non-Boot context (the Micronaut path the PR 
description mentions, plain Spring contexts, tests) is serialised on one 
thread, and a promise that waits on another promise deadlocks. The previous 
default was a cached thread pool.
   *Probe:* promise A awaiting a latch that promise B releases → A times out; 
executor reports `core=1 max=2147483647 queueCapacity=2147483647`.
   *Fix:* mirror Boot's defaults (core 8 / unbounded queue), or 
`SimpleAsyncTaskExecutor` (optionally virtual threads on Java 21), or simply 
fall back to `new CompletableFuturePromiseFactory()` and wrap its executor with 
the decorators. Add a two-promise concurrency test.
   
   ### Medium
   
   **M1. `WebPromises` no longer propagates the request unless the plugin bean 
has been instantiated.**
   Previously `WebPromises.getPromiseFactory()` / `setPromiseFactory()` always 
attached `AsyncWebRequestPromiseDecoratorLookupStrategy`, so `task { render ... 
}` worked in controller unit tests and in any context where 
`grailsPromiseFactory` had not been created yet. Now propagation lives only on 
the Spring-registered executor; `WebPromises.groovy:50` builds a bare 
`CompletableFuturePromiseFactory` with its own cached pool and no 
`GrailsWebRequestTaskDecorator`. `grails-testing-support-web` does not load 
`ControllersAsyncGrailsPlugin`, so `ControllerUnitTest` specs of async actions 
lose `render`/`params`/`request` inside `task {}`. The rewritten 
`WebPromisesSpec` masks this by wiring the decorator manually in `setup()`.
   *Probe:* `WebPromises.promiseFactory = null`, bind a `GrailsWebRequest`, 
`WebPromises.task { GrailsWebRequest.lookup() }.get()` → `null` (was a 
`GrailsWebRequest`).
   *Fix:* when `WebPromises` builds its default factory, wrap the executor with 
`GrailsWebRequestTaskDecorator` (or keep a lookup strategy that applies it). 
Restore a spec that does not pre-wire the decorator.
   
   **M2. `get()` exception type differs between a promise and its `then {}` 
child.**
   `CompletableFuturePromiseFactory.groovy:81` passes `unwrapFailureOnGet = 
false`, while `fromStage`'s default at `CompletableFuturePromise.groovy:127` is 
`true`, and `onComplete`/`then` use the default. So `createPromise { throw X 
}.get()` throws `ExecutionException(X)` (matching 
`FutureTaskPromiseFactorySpec` and `PromiseListSpec`), but `createPromise { 
throw X }.then { it }.get()` throws `X` directly. Lines 105/118 also 
sneaky-throw a checked `Throwable` from a method declared `throws 
ExecutionException`, which Java callers cannot catch by declaration. Pick one 
behaviour (the legacy `ExecutionException` one keeps compatibility) and cover 
it.
   *Probe:* `direct=java.util.concurrent.ExecutionException 
chained=java.lang.IllegalStateException`.
   
   **M3. Docs still present the deprecated Servlet API as current, and the 
upgrade guide is silent.**
   `grails-doc/src/en/guide/async/asyncServletApi.adoc:25` and 
`traits/traitsprovided.adoc:35` document `AsyncController`/`startAsync()` with 
no deprecation notice, and `upgrading/upgrading80x.adoc` has nothing on: the 
executor switch (pool size now governed by `spring.task.execution.*`, 8 core 
threads instead of unbounded), the deprecations, the `ExecutorPromiseFactory` 
removal from the default factory, or the events-bus change. Per the repo rule 
that user-facing changes ship with docs, please add an 8.0 upgrade note and 
mark the Servlet section deprecated.
   
   ### Low
   
   **L1. `onError(List, Closure)` hands the callback a freshly built 
`ExecutionException(cause)`** (`CompletableFuturePromiseFactory.groovy:128`) 
while single-promise `onError` hands over the unwrapped cause. Callers of 
`Promises.onError(list) { e -> }` and `promise.onError { e -> }` get different 
types for the same failure. Align them.
   
   **L2. Timeout inconsistency between the two entry points.** 
`WebPromises.prepareAsyncRequest()` (`WebPromises.groovy:192`) sets an infinite 
timeout, whereas an action returning a `Promises.task {}` / GORM `Domain.async` 
promise goes through the transformer with the container default (Tomcat: 30 s). 
This mirrors the old split, so not a regression, but now that the flow is 
Spring-managed it would be natural to honour `spring.mvc.async.request-timeout` 
via `DeferredResult(timeout)` in both paths.
   
   **L3. The "task after async completion" guard is gone.** The old decorator 
threw `Cannot start a task once asynchronous request processing has completed`; 
now such a task captures and later touches a recycled servlet request. Consider 
checking `asyncManager.asyncWebRequest?.isAsyncComplete()` in 
`prepareAsyncRequest()`.
   
   **L4. `grailsWebRequestTaskDecorator` is a global `TaskDecorator` bean**, so 
Boot also applies it to `@Async` methods and (Boot ≥ 3.5) the task scheduler. 
Harmless when no request is bound, but it is a behaviour change for `@Async` 
methods called from controllers (they now see a `GrailsWebRequest` copy). Worth 
a sentence in the docs.
   
   **L5. Commit history.** `479f5e0e2e Restore URL mapping state on async 
dispatch` and `c982feb634 Reuse matched URL mappings on async dispatch` cancel 
each other out (`UrlMappingsHandlerMapping` ends up unchanged). Please squash 
before merge per the contribution guide.
   
   ---
   
   ### Verified as correct
   
   - **Fallback bean does not suppress Boot's executor.** `OnBeanCondition` in 
Boot 4.1.1 filters fallback definitions (`getNonFallbackBeans`/`isNotFallback` 
→ `BeanDefinition.isFallback()`), and 
`TaskExecutorConfigurations$OnExecutorCondition$ExecutorBeanCondition` is 
`@ConditionalOnMissingBean(Executor.class)`, so `grailsPromiseExecutor` does 
not stop `applicationTaskExecutor` from being created.
   - **Boot composes multiple `TaskDecorator` beans**: 
`ThreadPoolTaskExecutorBuilderConfiguration` takes 
`ObjectProvider<TaskDecorator>`, uses `orderedStream()` and 
`getTaskDecorator(...)`, so `GrailsWebRequestTaskDecorator` is applied 
alongside user decorators on `applicationTaskExecutor`.
   - **`AsyncActionResultTransformer` + `UrlMappingsInfoHandlerAdapter`** now 
follow the same protocol as Spring's `RequestMappingHandlerAdapter` 
(`hasConcurrentResult()` → read → `clearConcurrentResult()` → rethrow 
`Exception` / return `ModelAndView`), and a `null` concurrent result correctly 
falls through to "no view" for actions that `render` inside the task (the 
`AsyncFunctionalSpec` path).
   - **Joining an eagerly started async cycle**: 
`StandardServletAsyncWebRequest.startAsync()` is a no-op when already started, 
so `WebPromises.task {}` followed by returning the promise works (covered by 
the new transformer spec).
   - **Executor affinity**: `defaultExecutor()` + `newIncompleteFuture()` keep 
`thenApplyAsync` etc. on the configured executor; the decorator therefore also 
covers derived stages.
   - **Explicit decorators are honoured** by `WebPromises.createPromise(c, 
decorators)` — the old code silently replaced them with the lookup result, so 
this is a bug fix.
   - **`@PreDestroy close()`** only shuts down an executor the factory owns, so 
the Boot-managed executor is left to Boot's lifecycle.
   - All three affected module suites and their code-style checks pass on a 
fresh run.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to