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

   ## Review round 2
   
   **Head:** `b65a64ed69` (`feature/modernize-grails-async-8.0.x`, 18 
first-parent commits) · **Base:** `8.0.x` @ `a5758d657f`. The head merges 
upstream `8.0.x` up to the #15960 merge (`VirtualThreadPromiseFactory`); the 
two `8.0.x` merges after that (#16322, #16184) are not included and 
`upgrading80x.adoc` conflicts with them (N9).
   
   Six commits since round 1: five fix commits (`f269620071` … `a29efe7561`) 
reviewed in detail below, plus the upstream merge `b65a64ed69`, covered in the 
post-merge addendum. The "Verified as correct" list from round 1 still holds.
   
   **What I ran locally** (on the merged head `b65a64ed69`; the N1/N2 probes 
were run on `a29efe7561`, and none of the files they exercise changed in the 
merge)
   
   | Check | Result |
   |---|---|
   | `:grails-async-core:cleanTest test` (`--no-build-cache`) | 69 tests, 0 
failures, 1 skipped |
   | `:grails-async:cleanTest test` (`--no-build-cache`) | 27 tests, 0 failures 
|
   | `:grails-events-core:cleanTest test` (`--no-build-cache`) | 12 tests, 0 
failures |
   | `:grails-async-core:codeStyle :grails-async:codeStyle 
:grails-events-core:codeStyle` | pass |
   | `git merge-tree origin/8.0.x pr/16230` | conflict in `upgrading80x.adoc` 
only |
   | Throwaway probe spec (deleted afterwards) for N1 and N2 below | output 
quoted per finding |
   
   **Verdict: request changes, but close.** All four High findings from round 1 
are fixed and covered by tests (see "Round 1 status" at the end). The rework of 
the exception path exposed one new regression that the new tests miss because 
they only throw unchecked exceptions, and the standalone `WebPromises` fix 
restored propagation for the task closure but not for `then {}` callbacks, 
which the 7.x implementation did propagate.
   
   ---
   
   ### High
   
   **N1. Checked exceptions thrown from a task reach `get()`/`onError` wrapped 
in `UndeclaredThrowableException`.**
   `CompletableFuturePromiseFactory.groovy:78` submits `{ decorated.call() } as 
Supplier<T>`. Under `@CompileStatic` the `as` coercion still produces a 
`java.lang.reflect.Proxy` over `ConvertedClosure`; `Supplier.get()` declares no 
checked exceptions, so the proxy wraps any checked exception in 
`UndeclaredThrowableException` before `CompletableFuture` ever sees it. 
`unwrap()` (`CompletableFuturePromise.groovy:109`) only strips 
`CompletionException`/`ExecutionException`, so the wrapper survives. The same 
coercion sits in `asCompletableFuture` 
(`CompletableFuturePromiseFactory.groovy:144`, where `promise.get()` itself 
throws checked `ExecutionException`) and in the new standalone web decorator 
(`WebPromises.groovy:71`, `{ … } as Runnable`).
   The old `FutureTask` implementation ran a `Callable`, so `task { throw new 
IOException() }.get()` used to throw `ExecutionException(IOException)` and 
`onError` received the `IOException`. Groovy code throws checked exceptions 
without declaring them all the time, so this is the common case, not an edge 
case, and it breaks `exception:` URL-mapping matches and `onError` type checks 
for every `IOException`/`SQLException`/`TimeoutException` coming out of a task. 
The new `CompletableFuturePromiseFactorySpec` cases all throw 
`IllegalStateException`, which is why they pass.
   *Probe (standalone factory, `Promises.task { throw new IOException('io') 
}`):*
   ```
   direct get():  ExecutionException -> UndeclaredThrowableException -> 
IOException
   onError sees:  UndeclaredThrowableException (cause IOException)
   WebPromises:   UndeclaredThrowableException (cause IOException)
   ```
   *Fix:* never let a closure cross a proxy boundary. Either catch `Throwable` 
inside the coerced closure and complete the future yourself (`new 
CompletableFuturePromise(executor)` + `executor.execute { try { 
promise.complete(decorated.call()) } catch (Throwable t) { 
promise.completeExceptionally(t) } }`), or give `supplyAsync` a real `Supplier` 
(a Groovy lambda `() -> decorated.call()` compiles to invokedynamic, not a 
proxy). Apply the same to `asCompletableFuture` and to the `Runnable` in 
`WebPromises` (catch inside, stash, rethrow after `run()` returns). Add 
`IOException` variants of the three new `CompletableFuturePromiseFactorySpec` 
cases and one in `WebPromisesSpec`; they fail today.
   
   ### Medium
   
   **N2. Standalone `WebPromises` propagates the request into `task {}` but not 
into `then {}`.**
   The M1 fix decorates the *closure* (`WebPromises.groovy:53-76`), so the 
request is bound only for the duration of `work.call()` and reset in 
`GrailsWebRequestTaskDecorator`'s `finally` before `Supplier.get()` returns. 
`CompletableFuture.AsyncSupply.run()` then calls `postComplete()`, which is 
where `then {}` dependents (`CompletableFuturePromise.groovy:63`, plain 
`thenApply`) execute, so they run without a `GrailsWebRequest`. In 7.x 
`FutureTaskChildPromise.groovy:57` applied the factory decorators to every 
`then` callback, so `task { load() }.then { render it }` worked in 
`ControllerUnitTest` specs; it now silently loses `render`/`params` in the 
callback. The Boot path is fine because the `TaskDecorator` wraps the whole 
`run()`, including `postComplete()`.
   *Probe (`WebPromises.promiseFactory = null`, latch so `then` is attached 
before the task completes):*
   ```
   standalone: task sees request: true; then sees request: false
   Boot-style executor + GrailsWebRequestTaskDecorator: then sees request: true
   ```
   *Fix:* decorate the *executor*, not the closure, exactly as the plugin does: 
when `PromiseFactoryBuilder` yields a `CompletableFuturePromiseFactory`, build 
it over `Executors.newCachedThreadPool()` wrapped so each `Runnable` goes 
through `GrailsWebRequestTaskDecorator`; keep the lookup-strategy path only for 
third-party factories from `ServiceLoader`. That also removes the extra proxy 
boundary from N1. Add a `then {}` case to `WebPromisesSpec` that attaches the 
callback before the task completes (the current parameters test attaches 
nothing).
   
   **N3. `spring.mvc.async.request-timeout` now fires by default for 
`WebPromises`, and a timeout renders as a logged 500, not a 503.**
   Honouring the property is right, but the effect is now reachable in every 
app: Tomcat's 30 s default applies to `WebPromises.task {}` where it used to be 
infinite. When it fires, Spring's `TimeoutDeferredResultProcessingInterceptor` 
sets `AsyncRequestTimeoutException` as the concurrent result, 
`UrlMappingsInfoHandlerAdapter.groovy:94` rethrows it, and 
`GrailsExceptionResolver.java:166` unconditionally sets status 500 and logs the 
full stack trace at ERROR. Spring MVC's own resolvers map that exception to 503 
without a stack trace. The docs (`asyncRequests.adoc:32`) say only that the 
timeout "ends the response lifecycle". Please either map 
`AsyncRequestTimeoutException` (it is an `ErrorResponse`) to its status code in 
`GrailsExceptionResolver`, or document that the app should add 
`"503"(controller: …, exception: AsyncRequestTimeoutException)` and that the 30 
s Tomcat default now applies to eager web promises. A functional test that 
times out a `WebPromises.task {}` and asserts th
 e status would pin this.
   
   ### Low
   
   **N4. Silently dropping a promise returned after the async request 
completed.** `AsyncActionResultTransformer.groovy:51` returns `null` in that 
case, so the action's result is discarded, the task keeps running against a 
recycled request, and nothing is logged. `WebPromises.prepareAsyncRequest` 
throws `IllegalStateException` for the same situation; the transformer should 
be consistent (throw, or at least log at WARN) rather than swallow.
   
   **N5. `GrailsAsyncWebRequest` naming and small nits.** The class is a static 
helper, not a web request, and it sits next to the deprecated 
`AsyncGrailsWebRequest`, which is going to confuse readers; 
`AsyncRequestSupport` or folding the two static methods into `WebPromises`/the 
transformer would be clearer. `GrailsAsyncWebRequest.groovy:41` uses `def` 
under `@CompileStatic`; and the timeout property is re-read and re-parsed with 
`DurationStyle` on every async request (`:44`). Reading the `WebMvcProperties` 
bean once, or caching the parsed value, would avoid that.
   
   **N6. Fallback for a mistyped `applicationTaskExecutor` is silent.** 
`ControllersAsyncGrailsPlugin.groovy:76-79` catches 
`NoSuchBeanDefinitionException` with the same bean name, which also matches 
`BeanNotOfRequiredTypeException` when an app defines `applicationTaskExecutor` 
as a plain `Executor`. That mirrors Boot's MVC behaviour, so it is acceptable, 
but a DEBUG/INFO line saying which executor the promise factory ended up with 
would make the fallback diagnosable.
   
   **N7. Squash still pending (round 1 L5).** The branch is now 18 first-parent 
commits including two upstream merges and the two URL-mapping commits that 
cancel each other out.
   
   ---
   
   ### Post-merge addendum (`b65a64ed69`, merge of upstream `8.0.x`)
   
   The merge itself is clean for this PR's own code: none of the files behind 
N1–N7 changed, the `PromiseFactoryBuilder` conflict was resolved keeping 
`build(Executor)`, the merged `VirtualThreadPromiseFactorySpec` was extended to 
assert the builder ignores a supplied executor under the opt-in, and all three 
module suites pass on the merged head. Two things do change.
   
   **N8 (Medium). The `grails.async.promiseFactory=virtual-thread` opt-in now 
bypasses everything this PR builds on.**
   `PromiseFactoryBuilder.groovy:49-55` returns `new 
VirtualThreadPromiseFactory()` before looking at the executor, and the merged 
spec pins that. Under the opt-in, in a Boot app, `grailsPromiseFactory` is 
therefore a `VirtualThreadPromiseFactory`, which:
   - is built on `FutureTaskPromise`, which this PR marks `@Deprecated(since = 
'8.0', forRemoval = true)` (`FutureTaskPromise.groovy:43`). Either the 
deprecation is premature, or the new factory needs to move off it before it 
ships in the same release.
   - has no readable `executor` property (`private final ExecutorService 
executorService`), so `EventBusFactoryBean` falls through to the synchronous 
bus with the startup WARN. Round-1 H1 comes back for this mode, and 
`events.adoc:32` is wrong for it.
   - gets neither Boot's `TaskDecorator` (it owns its executor) nor a 
`PromiseDecoratorLookupStrategy`, because `WebPromises.setPromiseFactory` no 
longer attaches one (`WebPromises.groovy:60`). On `8.0.x` today the old 
`setPromiseFactory` did attach `AsyncWebRequestPromiseDecoratorLookupStrategy`, 
so `WebPromises.task { render … }` works with the virtual-thread factory there 
and stops working with this PR. Same for `Promises.task {}` in a controller.
   - keeps the old aggregate `onError` contract (returns an empty list instead 
of failing), so the "consistent `ExecutionException` semantics" section of the 
docs is only true for the default factory.
   *Fix:* the simplest coherent option is to make `VirtualThreadPromiseFactory` 
a thin `CompletableFuturePromiseFactory` over 
`Executors.newVirtualThreadPerTaskExecutor()` (it then has the `executor` 
property, the same `get()`/`onError` semantics, `CompletionStage`, and no 
dependency on the deprecated classes), and have `ControllersAsyncGrailsPlugin` 
wrap whatever executor the built factory owns with the `TaskDecorator` beans. 
If that is out of scope for this PR, then at least restore the lookup strategy 
in `WebPromises.setPromiseFactory` for factories that are not 
`CompletableFuturePromiseFactory`, and say in `asyncPromises.adoc` that the 
opt-in does not share Boot's executor, event bus or request propagation. Add a 
plugin spec that sets the system property and asserts `grailsPromiseFactory` 
still propagates the request.
   
   **N9 (Low). Needs another merge or rebase before it can land.** `git 
merge-tree` against the current `origin/8.0.x` conflicts in 
`upgrading80x.adoc`: sections 62 and 63 were added upstream after this branch 
merged, so "64. Promise Execution and Async Request Migration" will need 
renumbering again. Nothing else conflicts.
   
   ---
   
   ### Round 1 status
   
   | Round 1 | Status | Notes |
   |---|---|---|
   | H1 event bus went synchronous | Fixed | `EventBusFactoryBean` reads the 
factory's `executor` property reflectively; `ExecutorEventBus` handles a plain 
`Executor` via `execute()`, no shutdown ownership. Covered by 
`TaskExecuterEventBusSpec`. |
   | H2 `unwrap` stripped one cause level | Fixed | Only 
`CompletionException`/`ExecutionException` with a non-null cause are unwrapped; 
transformer passes the failure straight to `setErrorResult`. Covered with a 
caused exception in both specs. See N1 for the remaining wrapper. |
   | H3 `NoUniqueBeanDefinitionException` with a second executor | Fixed | 
Resolved by name; spec registers `taskScheduler` + `otherExecutor`. |
   | H4 single-threaded fallback | Fixed | `corePoolSize = 8`; two-promise 
latch test. |
   | M1 standalone `WebPromises` lost the request | Partly fixed | Task closure 
OK; `then {}` not (N2). |
   | M2 `get()` type differed between promise and child | Fixed | Both now 
`ExecutionException(original)`, direct and timed; the sneaky-throw overrides 
are gone. |
   | M3 docs / upgrade note | Fixed | Upgrade section added (numbered 64 after 
the merge, see N9), Servlet API marked deprecated. |
   | L1 aggregate `onError` type | Fixed | Both callbacks get the original. |
   | L2 timeout consistency | Fixed | Both paths honour 
`spring.mvc.async.request-timeout` (see N3 for the consequence). |
   | L3 task-after-completion guard | Fixed | `COMPLETED` request attribute set 
from a completion handler, checked in both entry points. |
   | L4 decorator applies to `@Async` | Documented | |
   | L5 squash | Open | N7 |
   
   ### Verified as correct in this round
   
   - `CompletableFuture` semantics for the fixed `get()` path: a `supplyAsync` 
failure reaches `whenComplete` as `CompletionException`, `fromStage` completes 
the promise with the unwrapped original, and both the direct promise and its 
`thenApply` child report `ExecutionException(original)` from `get()` and 
`get(timeout)`. Spec matches observed behaviour.
   - `WebAsyncManager` removes itself on completion (Spring 6.2+), so the 
`COMPLETED` attribute is needed; the completion handler runs in 
`AsyncListener.onComplete`, before Tomcat recycles the request, and the 
attribute is cleared with the request. Dispatch reuses the same request object 
with no `onComplete` in between, so the guard does not trip mid-dispatch.
   - `DurationStyle.detectAndParse` defaults to milliseconds for a bare number, 
matching Boot's binding of `spring.mvc.async.request-timeout`; `spring-boot` is 
on the plugin's compile classpath transitively via `grails-core`'s `api` 
dependency.
   - `ThreadPoolTaskExecutor` with `corePoolSize = 8` and the default unbounded 
queue matches Boot's defaults as documented.
   - `EventBusFactoryBean`'s reflective lookup: 
`CompletableFuturePromiseFactory.executor` is a public final property; 
GPars/RxJava factories have no such property and keep the previous synchronous 
fallback.
   - The `TaskDecorator`-on-executor design does propagate the request into 
`then {}` dependents in Boot mode (probe P3 above), because `AsyncSupply.run()` 
calls `postComplete()` inside the decorated `Runnable`.
   - All three module suites and their code-style checks pass on a fresh, 
cache-bypassed 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