Skip to content

Commit 5e5898d

Browse files
committed
GROOVY-9381: Add native async/await support
1 parent fa02ed1 commit 5e5898d

36 files changed

Lines changed: 23899 additions & 4 deletions

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ dependencies {
118118
testImplementation "com.thoughtworks.qdox:qdox:${versions.qdox}"
119119
testImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
120120
testImplementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${versions.jackson}"
121+
testImplementation "io.reactivex.rxjava3:rxjava:${versions.rxjava3}"
122+
testImplementation "io.projectreactor:reactor-core:${versions.reactor}"
121123

122124
testFixturesImplementation projects.groovyXml
123125
testFixturesImplementation projects.groovyTest

gradle/verification-metadata.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
<ignored-key id="C71FB765CD9DE313" reason="Key couldn't be downloaded from any key server"/>
117117
<ignored-key id="C7CA19B7B620D787" reason="Key couldn't be downloaded from any key server"/>
118118
<ignored-key id="CA80D1F0EB6CA4BA" reason="Key couldn't be downloaded from any key server"/>
119+
<ignored-key id="D1031D14464180E0" reason="Key couldn't be downloaded from any key server"/>
119120
<ignored-key id="D2151178A123C97F" reason="Key couldn't be downloaded from any key server"/>
120121
<ignored-key id="D364ABAA39A47320" reason="Key couldn't be downloaded from any key server"/>
121122
<ignored-key id="D7742D58455ECC7C" reason="Key couldn't be downloaded from any key server"/>
@@ -874,6 +875,16 @@
874875
<sha512 value="f220e44fe6b61f8dbb61226f832dfb16a09584384540fd48a4dff5c4de9fee060623f85cbead720dfe776aa25105949e70758a9bb1d9db43f63068d8d22164c9" origin="Generated by Gradle"/>
875876
</artifact>
876877
</component>
878+
<component group="io.projectreactor" name="reactor-core" version="3.7.3">
879+
<artifact name="reactor-core-3.7.3.jar">
880+
<pgp value="48B086A7D843CFA258E83286928FBF39003C0425"/>
881+
</artifact>
882+
</component>
883+
<component group="io.reactivex.rxjava3" name="rxjava" version="3.1.10">
884+
<artifact name="rxjava-3.1.10.jar">
885+
<pgp value="E9CC3CD1AE59E851E4DB3FA350FFD7487D34B5B9"/>
886+
</artifact>
887+
</component>
877888
<component group="jakarta.activation" name="jakarta.activation-api" version="1.2.1">
878889
<artifact name="jakarta.activation-api-1.2.1.jar">
879890
<pgp value="6DD3B8C64EF75253BEB2C53AD908A43FB7EC07AC"/>
@@ -2229,6 +2240,11 @@
22292240
<sha512 value="adcc480f68828ffd68d03846be852988b595c2e1bb69224d273578dd6c2ad2773edfe96625a7c00bc40ae0f2d1cac8412eaa54b88cc8e681b0b4c0ee3b082333" origin="Generated by Gradle"/>
22302241
</artifact>
22312242
</component>
2243+
<component group="org.reactivestreams" name="reactive-streams" version="1.0.4">
2244+
<artifact name="reactive-streams-1.0.4.jar">
2245+
<sha512 value="cdab6bd156f39106cd6bbfd47df1f4b0a89dc4aa28c68c31ef12a463193c688897e415f01b8d7f0d487b0e6b5bd2f19044bf8605704b024f26d6aa1f4f9a2471" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
2246+
</artifact>
2247+
</component>
22322248
<component group="org.reflections" name="reflections" version="0.10.2">
22332249
<artifact name="reflections-0.10.2.jar">
22342250
<pgp value="3F2A008A91D11A7FAC4A0786F13D3E721D56BD54"/>

src/antlr/GroovyLexer.g4

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,9 @@ DEF : 'def';
392392
IN : 'in';
393393
TRAIT : 'trait';
394394
THREADSAFE : 'threadsafe'; // reserved keyword
395+
ASYNC : 'async';
396+
AWAIT : 'await';
397+
DEFER : 'defer';
395398

396399
// §3.9 Keywords
397400
BuiltInPrimitiveType

src/antlr/GroovyParser.g4

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ modifier
134134
| VOLATILE
135135
| DEF
136136
| VAR
137+
| ASYNC
137138
)
138139
;
139140

@@ -600,7 +601,7 @@ switchStatement
600601
;
601602

602603
loopStatement
603-
: FOR LPAREN forControl RPAREN nls statement #forStmtAlt
604+
: FOR AWAIT? LPAREN forControl RPAREN nls statement #forStmtAlt
604605
| WHILE expressionInPar nls statement #whileStmtAlt
605606
| DO nls statement nls WHILE expressionInPar #doWhileStmtAlt
606607
;
@@ -642,6 +643,8 @@ statement
642643
| continueStatement #continueStmtAlt
643644
| { inSwitchExpressionLevel > 0 }?
644645
yieldStatement #yieldStmtAlt
646+
| YIELD RETURN nls expression #yieldReturnStmtAlt
647+
| DEFER nls statementExpression #deferStmtAlt
645648
| identifier COLON nls statement #labeledStmtAlt
646649
| assertStatement #assertStmtAlt
647650
| localVariableDeclaration #localVariableDeclarationStmtAlt
@@ -778,6 +781,10 @@ expression
778781
// must come before postfixExpression to resolve the ambiguities between casting and call on parentheses expression, e.g. (int)(1 / 2)
779782
: castParExpression castOperandExpression #castExprAlt
780783

784+
// async closure/lambda must come before postfixExpression to resolve the ambiguities between async and method call, e.g. async { ... }
785+
| ASYNC nls closureOrLambdaExpression #asyncClosureExprAlt
786+
| AWAIT nls (LPAREN expression RPAREN | expression) #awaitExprAlt
787+
781788
// qualified names, array expressions, method invocation, post inc/dec
782789
| postfixExpression #postfixExprAlt
783790

@@ -1228,6 +1235,9 @@ identifier
12281235
: Identifier
12291236
| CapitalizedIdentifier
12301237
| AS
1238+
| ASYNC
1239+
| AWAIT
1240+
| DEFER
12311241
| IN
12321242
| PERMITS
12331243
| RECORD
@@ -1246,12 +1256,15 @@ keywords
12461256
: ABSTRACT
12471257
| AS
12481258
| ASSERT
1259+
| ASYNC
1260+
| AWAIT
12491261
| BREAK
12501262
| CASE
12511263
| CATCH
12521264
| CLASS
12531265
| CONST
12541266
| CONTINUE
1267+
| DEFER
12551268
| DEF
12561269
| DEFAULT
12571270
| DO
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package groovy.concurrent;
20+
21+
import groovy.lang.Closure;
22+
import org.apache.groovy.runtime.async.AsyncSupport;
23+
import org.apache.groovy.runtime.async.GroovyPromise;
24+
25+
import java.util.List;
26+
import java.util.Objects;
27+
import java.util.concurrent.CancellationException;
28+
import java.util.concurrent.CompletableFuture;
29+
import java.util.concurrent.CompletionException;
30+
import java.util.concurrent.CopyOnWriteArrayList;
31+
import java.util.concurrent.Executor;
32+
import java.util.concurrent.atomic.AtomicBoolean;
33+
34+
/**
35+
* Structured concurrency scope that ensures all child tasks complete
36+
* (or are cancelled) before the scope exits.
37+
*
38+
* <h2>Design philosophy</h2>
39+
* <p>Inspired by Swift's {@code TaskGroup}, Kotlin's {@code coroutineScope},
40+
* and JEP 453 (Structured Concurrency), {@code AsyncScope} provides a
41+
* bounded lifetime for async tasks. Unlike fire-and-forget
42+
* {@link AsyncSupport#executeAsync}, tasks launched within a scope are
43+
* guaranteed to complete before the scope closes. This prevents:
44+
* <ul>
45+
* <li>Orphaned tasks that outlive their logical parent</li>
46+
* <li>Resource leaks from uncollected async work</li>
47+
* <li>Silent failures from unobserved exceptions</li>
48+
* </ul>
49+
*
50+
* <h2>Failure policy</h2>
51+
* <p>By default, the scope uses a <b>fail-fast</b> policy: when any child
52+
* task completes exceptionally, all sibling tasks are cancelled immediately.
53+
* The first failure becomes the primary exception; subsequent failures are
54+
* added as {@linkplain Throwable#addSuppressed(Throwable) suppressed}
55+
* exceptions. This matches Kotlin's
56+
* {@code coroutineScope} / {@code supervisorScope} semantics.</p>
57+
*
58+
* <h2>Usage in Groovy</h2>
59+
* <pre>
60+
* import groovy.concurrent.AsyncScope
61+
* import groovy.concurrent.Awaitable
62+
*
63+
* def results = AsyncScope.withScope { scope -&gt;
64+
* def userTask = scope.async { fetchUser(id) }
65+
* def orderTask = scope.async { fetchOrders(id) }
66+
* return [user: await(userTask), orders: await(orderTask)]
67+
* }
68+
* // Both tasks guaranteed complete here
69+
* </pre>
70+
*
71+
* <h2>Thread safety</h2>
72+
* <p>All public methods are thread-safe. The child task list uses
73+
* {@link CopyOnWriteArrayList} for safe concurrent iteration during
74+
* cancellation. The {@link #closed} flag uses {@link AtomicBoolean}
75+
* with CAS for exactly-once close semantics.</p>
76+
*
77+
* @see Awaitable
78+
* @see AsyncSupport
79+
* @since 6.0.0
80+
*/
81+
public class AsyncScope implements AutoCloseable {
82+
83+
private final List<CompletableFuture<?>> children = new CopyOnWriteArrayList<>();
84+
private final AtomicBoolean closed = new AtomicBoolean(false);
85+
private final Executor executor;
86+
private final boolean failFast;
87+
88+
/**
89+
* Creates a new scope with the given executor and fail-fast policy.
90+
*
91+
* @param executor the executor for child tasks; must not be {@code null}
92+
* @param failFast if {@code true}, cancel all siblings when any child fails
93+
*/
94+
public AsyncScope(Executor executor, boolean failFast) {
95+
Objects.requireNonNull(executor, "executor must not be null");
96+
this.executor = executor;
97+
this.failFast = failFast;
98+
}
99+
100+
/**
101+
* Creates a new scope with the given executor and fail-fast enabled.
102+
*
103+
* @param executor the executor for child tasks; must not be {@code null}
104+
*/
105+
public AsyncScope(Executor executor) {
106+
this(executor, true);
107+
}
108+
109+
/**
110+
* Creates a new scope with the default async executor and fail-fast enabled.
111+
*/
112+
public AsyncScope() {
113+
this(AsyncSupport.getExecutor(), true);
114+
}
115+
116+
/**
117+
* Launches a child task within this scope. The task's lifetime is
118+
* bound to the scope: when the scope is closed, all incomplete child
119+
* tasks are cancelled.
120+
*
121+
* @param body the async body to execute
122+
* @param <T> the result type
123+
* @return an {@link Awaitable} representing the child task
124+
* @throws IllegalStateException if the scope has already been closed
125+
*/
126+
@SuppressWarnings("unchecked")
127+
public <T> Awaitable<T> async(Closure<T> body) {
128+
if (closed.get()) {
129+
throw new IllegalStateException("AsyncScope is closed — cannot launch new tasks");
130+
}
131+
CompletableFuture<T> cf = CompletableFuture.supplyAsync(() -> {
132+
try {
133+
return body.call();
134+
} catch (CompletionException ce) {
135+
throw ce;
136+
} catch (Throwable t) {
137+
throw new CompletionException(t);
138+
}
139+
}, executor);
140+
children.add(cf);
141+
if (failFast) {
142+
cf.whenComplete((v, err) -> {
143+
if (err != null && !closed.get()) {
144+
cancelAll();
145+
}
146+
});
147+
}
148+
return GroovyPromise.of(cf);
149+
}
150+
151+
/**
152+
* Returns the number of child tasks launched within this scope.
153+
*
154+
* @return the child task count
155+
*/
156+
public int getChildCount() {
157+
return children.size();
158+
}
159+
160+
/**
161+
* Cancels all child tasks. Idempotent — safe to call multiple times.
162+
* <p>
163+
* Cancels each child via {@link CompletableFuture#cancel(boolean)}.
164+
* Does <em>not</em> close the scope — the scope remains open so that
165+
* {@link #close()} can still join all children and collect errors.
166+
*/
167+
public void cancelAll() {
168+
for (CompletableFuture<?> child : children) {
169+
child.cancel(true);
170+
}
171+
}
172+
173+
/**
174+
* Waits for all child tasks to complete, then closes the scope.
175+
* <p>
176+
* If any child failed, the first failure is rethrown with subsequent
177+
* failures as {@linkplain Throwable#addSuppressed(Throwable) suppressed}
178+
* exceptions. Cancelled tasks are silently ignored.
179+
* <p>
180+
* This method is idempotent: only the first invocation waits for
181+
* children; subsequent calls are no-ops.
182+
*/
183+
@Override
184+
public void close() {
185+
if (!closed.compareAndSet(false, true)) return;
186+
Throwable firstError = null;
187+
for (CompletableFuture<?> child : children) {
188+
try {
189+
child.join();
190+
} catch (CancellationException ignored) {
191+
// Cancelled tasks are silently ignored
192+
} catch (CompletionException e) {
193+
Throwable cause = AsyncSupport.deepUnwrap(e);
194+
if (cause instanceof CancellationException) {
195+
continue;
196+
}
197+
if (firstError == null) {
198+
firstError = cause;
199+
} else {
200+
firstError.addSuppressed(cause);
201+
}
202+
} catch (Exception e) {
203+
if (firstError == null) {
204+
firstError = e;
205+
} else {
206+
firstError.addSuppressed(e);
207+
}
208+
}
209+
}
210+
if (firstError != null) {
211+
if (firstError instanceof RuntimeException re) throw re;
212+
if (firstError instanceof Error err) throw err;
213+
throw new RuntimeException(firstError);
214+
}
215+
}
216+
217+
/**
218+
* Convenience method that creates a scope, executes the given closure
219+
* within it, and ensures the scope is closed on exit.
220+
* <p>
221+
* The closure receives the {@code AsyncScope} as its argument and can
222+
* launch child tasks via {@link #async(Closure)}. The scope is
223+
* automatically closed (and all children awaited) when the closure
224+
* returns or throws.
225+
*
226+
* <pre>
227+
* def result = AsyncScope.withScope { scope -&gt;
228+
* def a = scope.async { computeA() }
229+
* def b = scope.async { computeB() }
230+
* return [await(a), await(b)]
231+
* }
232+
* </pre>
233+
*
234+
* @param body the closure to execute within the scope
235+
* @param <T> the result type
236+
* @return the closure's return value
237+
*/
238+
@SuppressWarnings("unchecked")
239+
public static <T> T withScope(Closure<T> body) {
240+
return withScope(AsyncSupport.getExecutor(), body);
241+
}
242+
243+
/**
244+
* Convenience method that creates a scope with the given executor,
245+
* executes the closure, and ensures the scope is closed on exit.
246+
*
247+
* @param executor the executor for child tasks
248+
* @param body the closure to execute within the scope
249+
* @param <T> the result type
250+
* @return the closure's return value
251+
*/
252+
@SuppressWarnings("unchecked")
253+
public static <T> T withScope(Executor executor, Closure<T> body) {
254+
try (AsyncScope scope = new AsyncScope(executor)) {
255+
return body.call(scope);
256+
}
257+
}
258+
}

0 commit comments

Comments
 (0)