Skip to content

Commit 096a878

Browse files
committed
Minor tweaks
1 parent be1215e commit 096a878

6 files changed

Lines changed: 895 additions & 12 deletions

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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.concurrent.CancellationException;
27+
import java.util.concurrent.CompletableFuture;
28+
import java.util.concurrent.CompletionException;
29+
import java.util.concurrent.CopyOnWriteArrayList;
30+
import java.util.concurrent.Executor;
31+
import java.util.concurrent.atomic.AtomicBoolean;
32+
33+
/**
34+
* Structured concurrency scope that ensures all child tasks complete
35+
* (or are cancelled) before the scope exits.
36+
*
37+
* <h2>Design philosophy</h2>
38+
* <p>Inspired by Swift's {@code TaskGroup}, Kotlin's {@code coroutineScope},
39+
* and JEP 453 (Structured Concurrency), {@code AsyncScope} provides a
40+
* bounded lifetime for async tasks. Unlike fire-and-forget
41+
* {@link AsyncSupport#executeAsync}, tasks launched within a scope are
42+
* guaranteed to complete before the scope closes. This prevents:
43+
* <ul>
44+
* <li>Orphaned tasks that outlive their logical parent</li>
45+
* <li>Resource leaks from uncollected async work</li>
46+
* <li>Silent failures from unobserved exceptions</li>
47+
* </ul>
48+
*
49+
* <h2>Failure policy</h2>
50+
* <p>By default, the scope uses a <b>fail-fast</b> policy: when any child
51+
* task completes exceptionally, all sibling tasks are cancelled immediately.
52+
* The first failure becomes the primary exception; subsequent failures are
53+
* added as {@linkplain Throwable#addSuppressed(Throwable) suppressed}
54+
* exceptions. This matches Kotlin's
55+
* {@code coroutineScope} / {@code supervisorScope} semantics.</p>
56+
*
57+
* <h2>Usage in Groovy</h2>
58+
* <pre>
59+
* import groovy.concurrent.AsyncScope
60+
* import groovy.concurrent.Awaitable
61+
*
62+
* def results = AsyncScope.withScope { scope -&gt;
63+
* def userTask = scope.async { fetchUser(id) }
64+
* def orderTask = scope.async { fetchOrders(id) }
65+
* return [user: await(userTask), orders: await(orderTask)]
66+
* }
67+
* // Both tasks guaranteed complete here
68+
* </pre>
69+
*
70+
* <h2>Thread safety</h2>
71+
* <p>All public methods are thread-safe. The child task list uses
72+
* {@link CopyOnWriteArrayList} for safe concurrent iteration during
73+
* cancellation. The {@link #closed} flag uses {@link AtomicBoolean}
74+
* with CAS for exactly-once close semantics.</p>
75+
*
76+
* @see Awaitable
77+
* @see AsyncSupport
78+
* @since 6.0.0
79+
*/
80+
public class AsyncScope implements AutoCloseable {
81+
82+
private final List<CompletableFuture<?>> children = new CopyOnWriteArrayList<>();
83+
private final AtomicBoolean closed = new AtomicBoolean(false);
84+
private final Executor executor;
85+
private final boolean failFast;
86+
87+
/**
88+
* Creates a new scope with the given executor and fail-fast policy.
89+
*
90+
* @param executor the executor for child tasks; must not be {@code null}
91+
* @param failFast if {@code true}, cancel all siblings when any child fails
92+
*/
93+
public AsyncScope(Executor executor, boolean failFast) {
94+
this.executor = executor;
95+
this.failFast = failFast;
96+
}
97+
98+
/**
99+
* Creates a new scope with the given executor and fail-fast enabled.
100+
*
101+
* @param executor the executor for child tasks; must not be {@code null}
102+
*/
103+
public AsyncScope(Executor executor) {
104+
this(executor, true);
105+
}
106+
107+
/**
108+
* Creates a new scope with the default async executor and fail-fast enabled.
109+
*/
110+
public AsyncScope() {
111+
this(AsyncSupport.getExecutor(), true);
112+
}
113+
114+
/**
115+
* Launches a child task within this scope. The task's lifetime is
116+
* bound to the scope: when the scope is closed, all incomplete child
117+
* tasks are cancelled.
118+
*
119+
* @param body the async body to execute
120+
* @param <T> the result type
121+
* @return an {@link Awaitable} representing the child task
122+
* @throws IllegalStateException if the scope has already been closed
123+
*/
124+
@SuppressWarnings("unchecked")
125+
public <T> Awaitable<T> async(Closure<T> body) {
126+
if (closed.get()) {
127+
throw new IllegalStateException("AsyncScope is closed — cannot launch new tasks");
128+
}
129+
CompletableFuture<T> cf = CompletableFuture.supplyAsync(() -> {
130+
try {
131+
return body.call();
132+
} catch (CompletionException ce) {
133+
throw ce;
134+
} catch (Throwable t) {
135+
throw new CompletionException(t);
136+
}
137+
}, executor);
138+
children.add(cf);
139+
if (failFast) {
140+
cf.whenComplete((v, err) -> {
141+
if (err != null && !closed.get()) {
142+
cancelAll();
143+
}
144+
});
145+
}
146+
return GroovyPromise.of(cf);
147+
}
148+
149+
/**
150+
* Returns the number of child tasks launched within this scope.
151+
*
152+
* @return the child task count
153+
*/
154+
public int getChildCount() {
155+
return children.size();
156+
}
157+
158+
/**
159+
* Cancels all child tasks. Idempotent — safe to call multiple times.
160+
* <p>
161+
* Cancels each child via {@link CompletableFuture#cancel(boolean)}.
162+
* Does <em>not</em> close the scope — the scope remains open so that
163+
* {@link #close()} can still join all children and collect errors.
164+
*/
165+
public void cancelAll() {
166+
for (CompletableFuture<?> child : children) {
167+
child.cancel(true);
168+
}
169+
}
170+
171+
/**
172+
* Waits for all child tasks to complete, then closes the scope.
173+
* <p>
174+
* If any child failed, the first failure is rethrown with subsequent
175+
* failures as {@linkplain Throwable#addSuppressed(Throwable) suppressed}
176+
* exceptions. Cancelled tasks are silently ignored.
177+
* <p>
178+
* This method is idempotent: only the first invocation waits for
179+
* children; subsequent calls are no-ops.
180+
*/
181+
@Override
182+
public void close() {
183+
if (!closed.compareAndSet(false, true)) return;
184+
Throwable firstError = null;
185+
for (CompletableFuture<?> child : children) {
186+
try {
187+
child.join();
188+
} catch (CancellationException ignored) {
189+
// Cancelled tasks are silently ignored
190+
} catch (CompletionException e) {
191+
Throwable cause = AsyncSupport.deepUnwrap(e);
192+
if (cause instanceof CancellationException) {
193+
continue;
194+
}
195+
if (firstError == null) {
196+
firstError = cause;
197+
} else {
198+
firstError.addSuppressed(cause);
199+
}
200+
} catch (Exception e) {
201+
if (firstError == null) {
202+
firstError = e;
203+
} else {
204+
firstError.addSuppressed(e);
205+
}
206+
}
207+
}
208+
if (firstError != null) {
209+
if (firstError instanceof RuntimeException re) throw re;
210+
if (firstError instanceof Error err) throw err;
211+
throw new RuntimeException(firstError);
212+
}
213+
}
214+
215+
/**
216+
* Convenience method that creates a scope, executes the given closure
217+
* within it, and ensures the scope is closed on exit.
218+
* <p>
219+
* The closure receives the {@code AsyncScope} as its argument and can
220+
* launch child tasks via {@link #async(Closure)}. The scope is
221+
* automatically closed (and all children awaited) when the closure
222+
* returns or throws.
223+
*
224+
* <pre>
225+
* def result = AsyncScope.withScope { scope -&gt;
226+
* def a = scope.async { computeA() }
227+
* def b = scope.async { computeB() }
228+
* return [await(a), await(b)]
229+
* }
230+
* </pre>
231+
*
232+
* @param body the closure to execute within the scope
233+
* @param <T> the result type
234+
* @return the closure's return value
235+
*/
236+
@SuppressWarnings("unchecked")
237+
public static <T> T withScope(Closure<T> body) {
238+
return withScope(AsyncSupport.getExecutor(), body);
239+
}
240+
241+
/**
242+
* Convenience method that creates a scope with the given executor,
243+
* executes the closure, and ensures the scope is closed on exit.
244+
*
245+
* @param executor the executor for child tasks
246+
* @param body the closure to execute within the scope
247+
* @param <T> the result type
248+
* @return the closure's return value
249+
*/
250+
@SuppressWarnings("unchecked")
251+
public static <T> T withScope(Executor executor, Closure<T> body) {
252+
try (AsyncScope scope = new AsyncScope(executor)) {
253+
return body.call(scope);
254+
}
255+
}
256+
}

0 commit comments

Comments
 (0)