Skip to content

Commit ccca236

Browse files
Add stack-safe async loop support with trampoline pattern (#1905)
- Add AsyncTrampoline class to prevent stack overflow in loops by converting callback recursion into iterative execution - Add thenRunWhileLoop method to AsyncRunnable to support while-loop semantics where condition is checked before body execution - Integrate trampoline into AsyncCallbackLoop by making LoopingCallback implement Runnable to avoid per-iteration lambda allocation JAVA-6120 --------- Co-authored-by: Almas Abdrazak <almas337519@gmail.com>
1 parent f5e7383 commit ccca236

5 files changed

Lines changed: 304 additions & 4 deletions

File tree

driver-core/src/main/com/mongodb/internal/async/AsyncRunnable.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,35 @@ default AsyncRunnable thenRunRetryingWhile(
243243
});
244244
}
245245

246+
/**
247+
* This method is equivalent to a while loop, where the condition is checked before each iteration.
248+
* If the condition returns {@code false} on the first check, the body is never executed.
249+
*
250+
* @param whileCheck a condition to check before each iteration; the loop continues as long as this condition returns true
251+
* @param loopBodyRunnable the asynchronous task to be executed in each iteration of the loop
252+
* @return the composition of this and the looping branch
253+
* @see AsyncCallbackLoop
254+
*/
255+
default AsyncRunnable thenRunWhileLoop(final BooleanSupplier whileCheck, final AsyncRunnable loopBodyRunnable) {
256+
return thenRun(finalCallback -> {
257+
LoopState loopState = new LoopState();
258+
new AsyncCallbackLoop(loopState, iterationCallback -> {
259+
260+
if (loopState.breakAndCompleteIf(() -> !whileCheck.getAsBoolean(), iterationCallback)) {
261+
return;
262+
}
263+
loopBodyRunnable.finish((result, t) -> {
264+
if (t != null) {
265+
iterationCallback.completeExceptionally(t);
266+
return;
267+
}
268+
iterationCallback.complete(iterationCallback);
269+
});
270+
271+
}).run(finalCallback);
272+
});
273+
}
274+
246275
/**
247276
* This method is equivalent to a do-while loop, where the loop body is executed first and
248277
* then the condition is checked to determine whether the loop should continue.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.mongodb.internal.async;
18+
19+
import com.mongodb.annotations.NotThreadSafe;
20+
import com.mongodb.lang.Nullable;
21+
22+
/**
23+
* A trampoline that converts recursive callback invocations into an iterative loop,
24+
* preventing stack overflow in async loops.
25+
*
26+
* <p>When async loop iterations complete synchronously on the same thread, callback
27+
* recursion occurs: each iteration's {@code callback.onResult()} immediately triggers
28+
* the next iteration, causing unbounded stack growth. For example, a 1000-iteration
29+
* loop would create > 1000 stack frames and cause {@code StackOverflowError}.</p>
30+
*
31+
* <p>The trampoline intercepts this recursion: instead of executing the next iteration
32+
* immediately (which would deepen the stack), it enqueues the continuation and returns, allowing
33+
* the stack to unwind. A flat loop at the top then processes enqueued continuation iteratively,
34+
* maintaining constant stack depth regardless of iteration count.</p>
35+
*
36+
* <p>Since async chains are sequential, at most one task is pending at any time.
37+
* The trampoline uses a single slot rather than a queue.</p>
38+
*
39+
* The first call on a thread becomes the "trampoline owner" and runs the drain loop.
40+
* Subsequent (re-entrant) calls on the same thread enqueue their continuation and return immediately.</p>
41+
*
42+
* <p>This class is not part of the public API and may be removed or changed at any time</p>
43+
*/
44+
@NotThreadSafe
45+
public final class AsyncTrampoline {
46+
47+
private static final ThreadLocal<ContinuationHolder> TRAMPOLINE = new ThreadLocal<>();
48+
49+
private AsyncTrampoline() {}
50+
51+
/**
52+
* Execute continuation through the trampoline. If no trampoline is active, become the owner
53+
* and drain all enqueued continuations. If a trampoline is already active, enqueue and return.
54+
*/
55+
public static void run(final Runnable continuation) {
56+
ContinuationHolder continuationHolder = TRAMPOLINE.get();
57+
if (continuationHolder != null) {
58+
continuationHolder.enqueue(continuation);
59+
} else {
60+
continuationHolder = new ContinuationHolder();
61+
TRAMPOLINE.set(continuationHolder);
62+
try {
63+
continuation.run();
64+
while (continuationHolder.continuation != null) {
65+
Runnable continuationToRun = continuationHolder.continuation;
66+
continuationHolder.continuation = null;
67+
continuationToRun.run();
68+
}
69+
} finally {
70+
TRAMPOLINE.remove();
71+
}
72+
}
73+
}
74+
75+
/**
76+
* A single-slot container for continuation.
77+
* At most one continuation is pending at any time in a sequential async chain.
78+
*/
79+
@NotThreadSafe
80+
private static final class ContinuationHolder {
81+
@Nullable
82+
private Runnable continuation;
83+
84+
void enqueue(final Runnable continuation) {
85+
if (this.continuation != null) {
86+
throw new AssertionError("Trampoline slot already occupied");
87+
}
88+
this.continuation = continuation;
89+
}
90+
}
91+
}

driver-core/src/main/com/mongodb/internal/async/function/AsyncCallbackLoop.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package com.mongodb.internal.async.function;
1717

1818
import com.mongodb.annotations.NotThreadSafe;
19+
import com.mongodb.internal.async.AsyncTrampoline;
1920
import com.mongodb.internal.async.SingleResultCallback;
2021
import com.mongodb.lang.Nullable;
2122

@@ -62,9 +63,11 @@ public void run(final SingleResultCallback<Void> callback) {
6263
@NotThreadSafe
6364
private class LoopingCallback implements SingleResultCallback<Void> {
6465
private final SingleResultCallback<Void> wrapped;
66+
private final Runnable nextIteration;
6567

6668
LoopingCallback(final SingleResultCallback<Void> callback) {
6769
wrapped = callback;
70+
nextIteration = () -> AsyncCallbackLoop.this.body.run(this);
6871
}
6972

7073
@Override
@@ -80,7 +83,7 @@ public void onResult(@Nullable final Void result, @Nullable final Throwable t) {
8083
return;
8184
}
8285
if (continueLooping) {
83-
body.run(this);
86+
AsyncTrampoline.run(nextIteration);
8487
} else {
8588
wrapped.onResult(result, null);
8689
}

driver-core/src/test/unit/com/mongodb/internal/async/AsyncFunctionsAbstractTest.java

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import static com.mongodb.assertions.Assertions.assertNotNull;
2828
import static com.mongodb.internal.async.AsyncRunnable.beginAsync;
29+
import static org.junit.jupiter.api.Assertions.assertEquals;
2930

3031
abstract class AsyncFunctionsAbstractTest extends AsyncFunctionsTestBase {
3132
private static final TimeoutContext TIMEOUT_CONTEXT = new TimeoutContext(new TimeoutSettings(0, 0, 0, 0L, 0));
@@ -723,6 +724,120 @@ void testTryCatchTestAndRethrow() {
723724
});
724725
}
725726

727+
@Test
728+
void testWhile() {
729+
// last iteration: 3 < 3 = 1
730+
// 1(plainTest exception) + 1(plainTest false) + 1(sync exception) + 1(sync success) * 1(transition to next iteration) = 4
731+
// 1(plainTest exception) + 1(plainTest false) + 1(sync exception) + 1(sync success) * 4(transition to next iteration) = 7
732+
// 1(plainTest exception) + 1(plainTest false) + 1(sync exception) + 1(sync success) * 7(transition to next iteration) = 10
733+
assertBehavesSameVariations(10,
734+
() -> {
735+
int counter = 0;
736+
while (counter < 3 && plainTest(counter)) {
737+
counter++;
738+
sync(counter);
739+
}
740+
},
741+
(callback) -> {
742+
MutableValue<Integer> counter = new MutableValue<>(0);
743+
beginAsync().thenRunWhileLoop(() -> counter.get() < 3 && plainTest(counter.get()), c2 -> {
744+
counter.set(counter.get() + 1);
745+
async(counter.get(), c2);
746+
}).finish(callback);
747+
});
748+
}
749+
750+
@Test
751+
void testWhileWithThenRun() {
752+
// while: last iteration: 3 < 3 = 1
753+
// 1(plainTest exception) + 1(plainTest false) + 1(sync exception) + 1(sync success) * 1(transition to next iteration) = 4
754+
// 1(plainTest exception) + 1(plainTest false) + 1(sync exception) + 1(sync success) * 4(transition to next iteration) = 7
755+
// 1(plainTest exception) + 1(plainTest false) + 1(sync exception) + 1(sync success) * 7(transition to next iteration) = 10
756+
// trailing sync: 1(exception) + 1(success) = 2
757+
// 6(while exception) + 4(while success) * 2(trailing sync) = 14
758+
assertBehavesSameVariations(14,
759+
() -> {
760+
int counter = 0;
761+
while (counter < 3 && plainTest(counter)) {
762+
counter++;
763+
sync(counter);
764+
}
765+
sync(counter + 1);
766+
},
767+
(callback) -> {
768+
MutableValue<Integer> counter = new MutableValue<>(0);
769+
beginAsync().thenRun(c -> {
770+
beginAsync().thenRunWhileLoop(() -> counter.get() < 3 && plainTest(counter.get()), c2 -> {
771+
counter.set(counter.get() + 1);
772+
async(counter.get(), c2);
773+
}).finish(c);
774+
}).thenRun(c -> {
775+
async(counter.get() + 1, c);
776+
}).finish(callback);
777+
});
778+
}
779+
780+
@Test
781+
void testNestedWhileLoops() {
782+
// inner while: 4 success + 6 exception = 10
783+
// last inner iteration: 3 < 3 = 1
784+
// 1(outer plainTest exception) + 1(outer plainTest false) + (inner while) * 1(transition to next iteration) = 12
785+
// 1(outer plainTest exception) + 1(outer plainTest false) + (inner while) * 12(transition to next iteration) = 56
786+
// 1(outer plainTest exception) + 1(outer plainTest false) + (inner while) * 56(transition to next iteration) = 232
787+
assertBehavesSameVariations(232,
788+
() -> {
789+
int outer = 0;
790+
while (outer < 3 && plainTest(outer)) {
791+
int inner = 0;
792+
while (inner < 3 && plainTest(inner)) {
793+
sync(outer + inner);
794+
inner++;
795+
}
796+
outer++;
797+
}
798+
},
799+
(callback) -> {
800+
MutableValue<Integer> outer = new MutableValue<>(0);
801+
beginAsync().thenRunWhileLoop(() -> outer.get() < 3 && plainTest(outer.get()), c -> {
802+
MutableValue<Integer> inner = new MutableValue<>(0);
803+
beginAsync().thenRunWhileLoop(
804+
() -> inner.get() < 3 && plainTest(inner.get()),
805+
c2 -> {
806+
beginAsync().thenRun(c3 -> {
807+
async(outer.get() + inner.get(), c3);
808+
}).thenRun(c3 -> {
809+
inner.set(inner.get() + 1);
810+
c3.complete(c3);
811+
}).finish(c2);
812+
}
813+
).thenRun(c2 -> {
814+
outer.set(outer.get() + 1);
815+
c2.complete(c2);
816+
}).finish(c);
817+
}).finish(callback);
818+
});
819+
}
820+
821+
@Test
822+
void testWhileLoopStackConstant() {
823+
int depthWith100 = maxStackDepthForIterations(100);
824+
int depthWith10000 = maxStackDepthForIterations(10_000);
825+
assertEquals(depthWith100, depthWith10000, "Stack depth should be constant regardless of iteration count (trampoline)");
826+
}
827+
828+
private int maxStackDepthForIterations(final int iterations) {
829+
MutableValue<Integer> counter = new MutableValue<>(0);
830+
MutableValue<Integer> maxDepth = new MutableValue<>(0);
831+
beginAsync().thenRunWhileLoop(() -> counter.get() < iterations, c -> {
832+
maxDepth.set(Math.max(maxDepth.get(), Thread.currentThread().getStackTrace().length));
833+
counter.set(counter.get() + 1);
834+
c.complete(c);
835+
}).finish((v, t) -> {});
836+
837+
assertEquals(iterations, counter.get());
838+
return maxDepth.get();
839+
}
840+
726841
@Test
727842
void testRetryLoop() {
728843
assertBehavesSameVariations(InvocationTracker.DEPTH_LIMIT * 2 + 1,
@@ -768,6 +883,65 @@ void testDoWhileLoop() {
768883
});
769884
}
770885

886+
@Test
887+
void testNestedDoWhileLoops() {
888+
// inner do-while: 3 success + 5 exception = 8
889+
// last outer iteration: 3 < 3 = 1
890+
// 5(inner exception) + 3(inner success) * 1(transition to next iteration) = 8
891+
// 5(inner exception) + 3(inner success) * (1(outer plainTest exception) + 1(outer plainTest false) + 8(transition to next iteration)) = 35
892+
// 5(inner exception) + 3(inner success) * (1(outer plainTest exception) + 1(outer plainTest false) + 35(transition to next iteration)) = 116
893+
assertBehavesSameVariations(116,
894+
() -> {
895+
int outer = 0;
896+
do {
897+
int inner = 0;
898+
do {
899+
sync(outer + inner);
900+
inner++;
901+
} while (inner < 3 && plainTest(inner));
902+
outer++;
903+
} while (outer < 3 && plainTest(outer));
904+
},
905+
(callback) -> {
906+
MutableValue<Integer> outer = new MutableValue<>(0);
907+
beginAsync().thenRunDoWhileLoop(c -> {
908+
MutableValue<Integer> inner = new MutableValue<>(0);
909+
beginAsync().thenRunDoWhileLoop(c2 -> {
910+
beginAsync().thenRun(c3 -> {
911+
async(outer.get() + inner.get(), c3);
912+
}).thenRun(c3 -> {
913+
inner.set(inner.get() + 1);
914+
c3.complete(c3);
915+
}).finish(c2);
916+
}, () -> inner.get() < 3 && plainTest(inner.get())
917+
).thenRun(c2 -> {
918+
outer.set(outer.get() + 1);
919+
c2.complete(c2);
920+
}).finish(c);
921+
}, () -> outer.get() < 3 && plainTest(outer.get())).finish(callback);
922+
});
923+
}
924+
925+
@Test
926+
void testDoWhileLoopStackConstant() {
927+
int depthWith100 = maxDoWhileStackDepthForIterations(100);
928+
int depthWith10000 = maxDoWhileStackDepthForIterations(10_000);
929+
assertEquals(depthWith100, depthWith10000,
930+
"Stack depth should be constant regardless of iteration count");
931+
}
932+
933+
private int maxDoWhileStackDepthForIterations(final int iterations) {
934+
MutableValue<Integer> counter = new MutableValue<>(0);
935+
MutableValue<Integer> maxDepth = new MutableValue<>(0);
936+
beginAsync().thenRunDoWhileLoop(c -> {
937+
maxDepth.set(Math.max(maxDepth.get(), Thread.currentThread().getStackTrace().length));
938+
counter.set(counter.get() + 1);
939+
c.complete(c);
940+
}, () -> counter.get() < iterations).finish((v, t) -> {});
941+
assertEquals(iterations, counter.get());
942+
return maxDepth.get();
943+
}
944+
771945
@Test
772946
void testFinallyWithPlainInsideTry() {
773947
// (in try: normal flow + exception + exception) * (in finally: normal + exception) = 6

driver-core/src/test/unit/com/mongodb/internal/async/AsyncFunctionsTestBase.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.function.Consumer;
3333
import java.util.function.Supplier;
3434

35+
import static java.lang.String.format;
3536
import static org.junit.jupiter.api.Assertions.assertEquals;
3637
import static org.junit.jupiter.api.Assertions.assertFalse;
3738
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -272,14 +273,16 @@ private <T> void assertBehavesSame(final Supplier<T> sync, final Runnable betwee
272273
}
273274

274275
assertTrue(wasCalledFuture.isDone(), "callback should have been called");
275-
assertEquals(expectedEvents, listener.getEventStrings(), "steps should have matched");
276-
assertEquals(expectedValue, actualValue.get());
277276
assertEquals(expectedException == null, actualException.get() == null,
278-
"both or neither should have produced an exception");
277+
format("both or neither should have produced an exception. Expected exception: %s, actual exception: %s",
278+
expectedException,
279+
actualException.get()));
279280
if (expectedException != null) {
280281
assertEquals(expectedException.getMessage(), actualException.get().getMessage());
281282
assertEquals(expectedException.getClass(), actualException.get().getClass());
282283
}
284+
assertEquals(expectedEvents, listener.getEventStrings(), "steps should have matched");
285+
assertEquals(expectedValue, actualValue.get());
283286

284287
listener.clear();
285288
}

0 commit comments

Comments
 (0)