From 14e580b7129e1f1de3b455660fc8c6cf0c5e08bf Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Mon, 9 Mar 2026 08:18:21 +1000 Subject: [PATCH 1/3] Add unit tests for PerLevelDataLoaderDispatchStrategy coverage gaps Cover concurrency-dependent code paths that were previously only exercised non-deterministically by integration tests, causing flaky coverage gate failures in PRs: - markLevelAsDispatchedIfReady: test the "another thread already dispatched" path (line 447) that requires two threads to race on dispatchedLevels.add() - executeObjectOnFieldValuesException: error recovery path never triggered by integration tests - executionStrategyOnFieldValuesException: same error recovery pattern at the root execution strategy level - Concurrent onCompletionFinished race: two threads completing the same level simultaneously Co-Authored-By: Claude Opus 4.6 --- ...erLevelDataLoaderDispatchStrategyTest.java | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java diff --git a/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java b/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java new file mode 100644 index 0000000000..54c4d8d884 --- /dev/null +++ b/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java @@ -0,0 +1,322 @@ +package graphql.execution.instrumentation.dataloader; + +import graphql.ExecutionInput; +import graphql.GraphQLContext; +import graphql.Profiler; +import graphql.execution.CoercedVariables; +import graphql.execution.ExecutionContext; +import graphql.execution.ExecutionContextBuilder; +import graphql.execution.ExecutionId; +import graphql.execution.ExecutionStepInfo; +import graphql.execution.ExecutionStrategy; +import graphql.execution.ExecutionStrategyParameters; +import graphql.execution.FieldValueInfo; +import graphql.execution.MergedField; +import graphql.execution.MergedSelectionSet; +import graphql.execution.NonNullableFieldValidator; +import graphql.execution.ResultPath; +import graphql.execution.ValueUnboxer; +import graphql.execution.instrumentation.SimplePerformantInstrumentation; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import org.dataloader.DataLoaderRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import static graphql.StarWarsSchema.starWarsSchema; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for concurrency-dependent code paths in {@link PerLevelDataLoaderDispatchStrategy} + * that are otherwise non-deterministically covered by integration tests. + */ +public class PerLevelDataLoaderDispatchStrategyTest { + + private ExecutionContext executionContext; + private PerLevelDataLoaderDispatchStrategy strategy; + private ExecutionStrategy dummyStrategy; + + @BeforeEach + void setUp() { + dummyStrategy = new graphql.execution.AsyncExecutionStrategy(); + ExecutionInput ei = ExecutionInput.newExecutionInput("{ hero { name } }").build(); + ExecutionContextBuilder builder = ExecutionContextBuilder.newExecutionContextBuilder() + .instrumentation(SimplePerformantInstrumentation.INSTANCE) + .executionId(ExecutionId.from("test")) + .graphQLSchema(starWarsSchema) + .queryStrategy(dummyStrategy) + .mutationStrategy(dummyStrategy) + .subscriptionStrategy(dummyStrategy) + .coercedVariables(CoercedVariables.emptyVariables()) + .graphQLContext(GraphQLContext.newContext().build()) + .executionInput(ei) + .root("root") + .dataLoaderRegistry(new DataLoaderRegistry()) + .locale(Locale.getDefault()) + .valueUnboxer(ValueUnboxer.DEFAULT) + .profiler(Profiler.NO_OP) + .engineRunningState(new graphql.EngineRunningState(ei, Profiler.NO_OP)); + executionContext = builder.build(); + strategy = new PerLevelDataLoaderDispatchStrategy(executionContext); + } + + private ExecutionStrategyParameters paramsAtLevel(int level) { + ResultPath path = ResultPath.rootPath(); + for (int i = 0; i < level; i++) { + path = path.segment("f" + i); + } + return ExecutionStrategyParameters.newParameters() + .executionStepInfo(ExecutionStepInfo.newExecutionStepInfo() + .type(graphql.Scalars.GraphQLString) + .path(path) + .build()) + .fields(MergedSelectionSet.newMergedSelectionSet().build()) + .nonNullFieldValidator(new NonNullableFieldValidator(executionContext)) + .path(path) + .build(); + } + + /** + * Tests that when two threads concurrently try to dispatch the same level, + * the second thread correctly returns false from markLevelAsDispatchedIfReady. + *

+ * This covers line 447 and the branch at line 445 which are otherwise + * only hit under specific thread timing in integration tests. + */ + @Test + void markLevelAsDispatchedIfReady_returnsFalse_whenAnotherThreadAlreadyDispatched() throws Exception { + // Access private initialCallStack field + Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); + callStackField.setAccessible(true); + Object callStack = callStackField.get(strategy); + + // Access dispatchedLevels on CallStack + Field dispatchedLevelsField = callStack.getClass().getDeclaredField("dispatchedLevels"); + dispatchedLevelsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set dispatchedLevels = (Set) dispatchedLevelsField.get(callStack); + + // Access stateForLevelMap to set up level 0 state + Field stateMapField = callStack.getClass().getDeclaredField("stateForLevelMap"); + stateMapField.setAccessible(true); + + // Set up the state through the public API: + // 1. executionStrategy initializes level 0: executeObjectCalls=1, expectedFirstLevelFetchCount=1 + ExecutionStrategyParameters rootParams = paramsAtLevel(0); + strategy.executionStrategy(executionContext, rootParams, 1); + + // 2. fieldFetched at level 1 dispatches level 1 (adds it to dispatchedLevels) + ExecutionStrategyParameters level1Params = paramsAtLevel(1); + strategy.fieldFetched(executionContext, level1Params, + (DataFetcher) env -> null, "value", + (Supplier) () -> null); + + // Verify level 1 is dispatched + assertTrue(dispatchedLevels.contains(1), "Level 1 should be dispatched after fieldFetched"); + + // Now level 0 has executeObjectCalls=1, completionFinished=0. + // When executionStrategyOnFieldValuesInfo is called, it increments completionFinished to 1, + // making level 2 ready (since 1==1 and level 1 is dispatched). + // But FIRST, simulate another thread having already dispatched level 2: + dispatchedLevels.add(2); + + // Now call the private markLevelAsDispatchedIfReady(2, callStack) via reflection. + // isLevelReady(2) will return true (level 1 dispatched, executeObjectCalls(0)==1, + // but completionFinished is 0, so we need to set it up). + // Actually, we need completionFinished == executeObjectCalls at level 0. + // Let's increment completionFinished by calling onCompletionFinished directly. + // But onCompletionFinished checks dispatchedLevels.contains(level+2) first. + // Since we added 2 to dispatchedLevels, onCompletionFinished(0) would break immediately. + + // Instead, set up the state directly: make level 0 have matching counts. + // We need to call executeObjectOnFieldValuesInfo at level 0 to increment completionFinished. + // But that also checks level 2... which we pre-added. So it would break. + + // The cleanest approach: remove level 2, call the public API to set up the state, + // then add level 2 back, then call markLevelAsDispatchedIfReady directly. + dispatchedLevels.remove(2); + + // Increment completionFinished at level 0 via executionStrategyOnFieldValuesInfo + // This will also trigger dispatch of level 2 through the normal path. + // Let's just set up the state via reflection instead. + + // Get the markLevelAsDispatchedIfReady method + Method markMethod = PerLevelDataLoaderDispatchStrategy.class + .getDeclaredMethod("markLevelAsDispatchedIfReady", int.class, callStack.getClass()); + markMethod.setAccessible(true); + + // Get isLevelReady to work: need completionFinished == executeObjectCalls at level 0 + // Level 0 currently has StateForLevel(completionFinished=0, executeObjectCalls=1) + // We need to set completionFinished=1. Use the get/tryUpdateLevel methods on callStack. + Method getMethod = callStack.getClass().getDeclaredMethod("get", int.class); + getMethod.setAccessible(true); + Object stateForLevel0 = getMethod.invoke(callStack, 0); + + Method increaseCompletionMethod = stateForLevel0.getClass() + .getDeclaredMethod("increaseHappenedCompletionFinishedCount"); + increaseCompletionMethod.setAccessible(true); + Object updatedState = increaseCompletionMethod.invoke(stateForLevel0); + + Method tryUpdateMethod = callStack.getClass() + .getDeclaredMethod("tryUpdateLevel", int.class, stateForLevel0.getClass(), stateForLevel0.getClass()); + tryUpdateMethod.setAccessible(true); + tryUpdateMethod.invoke(callStack, 0, stateForLevel0, updatedState); + + // Now level 0 has executeObjectCalls=1, completionFinished=1 → level 2 is ready. + + // First call: should return true (level 2 not yet dispatched) + boolean firstResult = (boolean) markMethod.invoke(strategy, 2, callStack); + assertTrue(firstResult, "First dispatch of level 2 should succeed"); + assertTrue(dispatchedLevels.contains(2), "Level 2 should be in dispatchedLevels"); + + // Second call: simulates another thread arriving — should return false (LINE 447) + boolean secondResult = (boolean) markMethod.invoke(strategy, 2, callStack); + assertFalse(secondResult, "Second dispatch of level 2 should return false (another thread already dispatched)"); + } + + /** + * Tests the concurrent race between two threads calling onCompletionFinished, + * both trying to dispatch the same level. Uses CountDownLatch to maximize + * the chance of the race occurring. + */ + @Test + void concurrentOnCompletionFinished_racesToDispatchSameLevel() throws Exception { + // Set up with executeObjectCalls=2 at level 0 so that both threads + // see completionFinished==executeObjectCalls after both increment + ExecutionStrategyParameters rootParams = paramsAtLevel(0); + strategy.executionStrategy(executionContext, rootParams, 1); + + // Increment executeObjectCalls at level 0 from 1 to 2 + ExecutionStrategyParameters level0Params = paramsAtLevel(0); + strategy.executeObject(executionContext, level0Params, 1); + + // Dispatch level 1 via fieldFetched + ExecutionStrategyParameters level1Params = paramsAtLevel(1); + strategy.fieldFetched(executionContext, level1Params, + (DataFetcher) env -> null, "value", + (Supplier) () -> null); + + // Now: level 0 has executeObjectCalls=2, completionFinished=0, level 1 is dispatched. + // Two threads calling executeObjectOnFieldValuesInfo (level 0) will both increment + // completionFinished. When both have incremented (to 2), isLevelReady(2) returns true + // for both, and they race to dispatchedLevels.add(2). + + CountDownLatch startLatch = new CountDownLatch(1); + AtomicBoolean thread1Result = new AtomicBoolean(false); + AtomicBoolean thread2Result = new AtomicBoolean(false); + AtomicBoolean raceOccurred = new AtomicBoolean(false); + + ExecutorService executor = Executors.newFixedThreadPool(2); + + // Both threads will call executeObjectOnFieldValuesInfo at level 0 + Runnable task = () -> { + try { + startLatch.await(); + strategy.executeObjectOnFieldValuesInfo( + Collections.emptyList(), level0Params); + } catch (Exception e) { + throw new RuntimeException(e); + } + }; + + executor.submit(task); + executor.submit(task); + + // Release both threads simultaneously + startLatch.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + // Access dispatchedLevels to verify level 2 was dispatched exactly once + Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); + callStackField.setAccessible(true); + Object callStack = callStackField.get(strategy); + Field dispatchedLevelsField = callStack.getClass().getDeclaredField("dispatchedLevels"); + dispatchedLevelsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set dispatchedLevels = (Set) dispatchedLevelsField.get(callStack); + + // Level 2 should be dispatched (regardless of which thread won the race) + assertTrue(dispatchedLevels.contains(2), + "Level 2 should be dispatched after both completions"); + } + + /** + * Tests executeObjectOnFieldValuesException — the error recovery path + * that is never triggered by integration tests. + */ + @Test + void executeObjectOnFieldValuesException_callsOnCompletionFinished() throws Exception { + ExecutionStrategyParameters rootParams = paramsAtLevel(0); + strategy.executionStrategy(executionContext, rootParams, 1); + + // Dispatch level 1 via fieldFetched + ExecutionStrategyParameters level1Params = paramsAtLevel(1); + strategy.fieldFetched(executionContext, level1Params, + (DataFetcher) env -> null, "value", + (Supplier) () -> null); + + // Call the error handler — this should not throw and should call onCompletionFinished + ExecutionStrategyParameters level2Params = paramsAtLevel(2); + strategy.executeObjectOnFieldValuesException( + new RuntimeException("test error"), level2Params); + + // Verify it ran without error — the completion count at level 2 should have incremented + Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); + callStackField.setAccessible(true); + Object callStack = callStackField.get(strategy); + + Method getMethod = callStack.getClass().getDeclaredMethod("get", int.class); + getMethod.setAccessible(true); + Object stateForLevel2 = getMethod.invoke(callStack, 2); + + Field completionField = stateForLevel2.getClass().getDeclaredField("happenedCompletionFinishedCount"); + completionField.setAccessible(true); + int completionCount = (int) completionField.get(stateForLevel2); + assertTrue(completionCount > 0, + "completionFinished should have been incremented by the exception handler"); + } + + /** + * Tests executionStrategyOnFieldValuesException — the error recovery path + * at the top-level execution strategy that is never triggered by integration tests. + */ + @Test + void executionStrategyOnFieldValuesException_callsOnCompletionFinished() throws Exception { + ExecutionStrategyParameters rootParams = paramsAtLevel(0); + strategy.executionStrategy(executionContext, rootParams, 1); + + // Call the error handler at the root level + strategy.executionStrategyOnFieldValuesException( + new RuntimeException("test error"), rootParams); + + // Verify completion count at level 0 was incremented + Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); + callStackField.setAccessible(true); + Object callStack = callStackField.get(strategy); + + Method getMethod = callStack.getClass().getDeclaredMethod("get", int.class); + getMethod.setAccessible(true); + Object stateForLevel0 = getMethod.invoke(callStack, 0); + + Field completionField = stateForLevel0.getClass().getDeclaredField("happenedCompletionFinishedCount"); + completionField.setAccessible(true); + int completionCount = (int) completionField.get(stateForLevel0); + assertTrue(completionCount > 0, + "completionFinished should have been incremented by the exception handler"); + } +} From e6bc2fac07865d44c987c1a3998eaac2281a4c56 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Mon, 9 Mar 2026 08:46:36 +1000 Subject: [PATCH 2/3] Replace reflection with @VisibleForTesting package-private access Make CallStack, its fields, and markLevelAsDispatchedIfReady package-private so tests can access them directly without reflection. Co-Authored-By: Claude Opus 4.6 --- .../PerLevelDataLoaderDispatchStrategy.java | 19 +- ...erLevelDataLoaderDispatchStrategyTest.java | 174 ++++-------------- 2 files changed, 46 insertions(+), 147 deletions(-) diff --git a/src/main/java/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategy.java b/src/main/java/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategy.java index e6bfc3f740..0626d19af3 100644 --- a/src/main/java/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategy.java +++ b/src/main/java/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategy.java @@ -13,6 +13,7 @@ import graphql.schema.DataFetchingEnvironment; import org.dataloader.DataLoader; import org.dataloader.DataLoaderRegistry; +import graphql.VisibleForTesting; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -30,7 +31,8 @@ @NullMarked public class PerLevelDataLoaderDispatchStrategy implements DataLoaderDispatchStrategy { - private final CallStack initialCallStack; + @VisibleForTesting + final CallStack initialCallStack; private final ExecutionContext executionContext; private final boolean enableDataLoaderChaining; @@ -145,7 +147,8 @@ public void clear() { } - private static class CallStack { + // package-private for testing + static class CallStack { /** * We track three things per level: @@ -177,8 +180,10 @@ private static class CallStack { */ static class StateForLevel { - private final int happenedCompletionFinishedCount; - private final int happenedExecuteObjectCalls; + @VisibleForTesting + final int happenedCompletionFinishedCount; + @VisibleForTesting + final int happenedExecuteObjectCalls; public StateForLevel() { @@ -216,7 +221,8 @@ public StateForLevel increaseHappenedExecuteObjectCalls() { private final Map> stateForLevelMap = new ConcurrentHashMap<>(); - private final Set dispatchedLevels = ConcurrentHashMap.newKeySet(); + @VisibleForTesting + final Set dispatchedLevels = ConcurrentHashMap.newKeySet(); public ChainedDLStack chainedDLStack = new ChainedDLStack(); @@ -439,7 +445,8 @@ private CallStack getCallStack(@Nullable AlternativeCallContext alternativeCallC } - private boolean markLevelAsDispatchedIfReady(int level, CallStack callStack) { + @VisibleForTesting + boolean markLevelAsDispatchedIfReady(int level, CallStack callStack) { boolean ready = isLevelReady(level, callStack); if (ready) { if (!callStack.dispatchedLevels.add(level)) { diff --git a/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java b/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java index 54c4d8d884..12de538f6d 100644 --- a/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java +++ b/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java @@ -10,8 +10,6 @@ import graphql.execution.ExecutionStepInfo; import graphql.execution.ExecutionStrategy; import graphql.execution.ExecutionStrategyParameters; -import graphql.execution.FieldValueInfo; -import graphql.execution.MergedField; import graphql.execution.MergedSelectionSet; import graphql.execution.NonNullableFieldValidator; import graphql.execution.ResultPath; @@ -23,17 +21,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.util.Collections; -import java.util.List; import java.util.Locale; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; import static graphql.StarWarsSchema.starWarsSchema; @@ -48,11 +42,10 @@ public class PerLevelDataLoaderDispatchStrategyTest { private ExecutionContext executionContext; private PerLevelDataLoaderDispatchStrategy strategy; - private ExecutionStrategy dummyStrategy; @BeforeEach void setUp() { - dummyStrategy = new graphql.execution.AsyncExecutionStrategy(); + ExecutionStrategy dummyStrategy = new graphql.execution.AsyncExecutionStrategy(); ExecutionInput ei = ExecutionInput.newExecutionInput("{ hero { name } }").build(); ExecutionContextBuilder builder = ExecutionContextBuilder.newExecutionContextBuilder() .instrumentation(SimplePerformantInstrumentation.INSTANCE) @@ -91,112 +84,50 @@ private ExecutionStrategyParameters paramsAtLevel(int level) { } /** - * Tests that when two threads concurrently try to dispatch the same level, - * the second thread correctly returns false from markLevelAsDispatchedIfReady. - *

- * This covers line 447 and the branch at line 445 which are otherwise - * only hit under specific thread timing in integration tests. + * Tests that when two calls try to dispatch the same level, + * the second call correctly returns false from markLevelAsDispatchedIfReady. */ @Test - void markLevelAsDispatchedIfReady_returnsFalse_whenAnotherThreadAlreadyDispatched() throws Exception { - // Access private initialCallStack field - Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); - callStackField.setAccessible(true); - Object callStack = callStackField.get(strategy); + void markLevelAsDispatchedIfReady_returnsFalse_whenAlreadyDispatched() { + PerLevelDataLoaderDispatchStrategy.CallStack callStack = strategy.initialCallStack; + Set dispatchedLevels = callStack.dispatchedLevels; - // Access dispatchedLevels on CallStack - Field dispatchedLevelsField = callStack.getClass().getDeclaredField("dispatchedLevels"); - dispatchedLevelsField.setAccessible(true); - @SuppressWarnings("unchecked") - Set dispatchedLevels = (Set) dispatchedLevelsField.get(callStack); - - // Access stateForLevelMap to set up level 0 state - Field stateMapField = callStack.getClass().getDeclaredField("stateForLevelMap"); - stateMapField.setAccessible(true); - - // Set up the state through the public API: - // 1. executionStrategy initializes level 0: executeObjectCalls=1, expectedFirstLevelFetchCount=1 + // Set up state through the public API: + // executionStrategy initializes level 0: executeObjectCalls=1, expectedFirstLevelFetchCount=1 ExecutionStrategyParameters rootParams = paramsAtLevel(0); strategy.executionStrategy(executionContext, rootParams, 1); - // 2. fieldFetched at level 1 dispatches level 1 (adds it to dispatchedLevels) + // fieldFetched at level 1 dispatches level 1 ExecutionStrategyParameters level1Params = paramsAtLevel(1); strategy.fieldFetched(executionContext, level1Params, (DataFetcher) env -> null, "value", (Supplier) () -> null); - // Verify level 1 is dispatched assertTrue(dispatchedLevels.contains(1), "Level 1 should be dispatched after fieldFetched"); - // Now level 0 has executeObjectCalls=1, completionFinished=0. - // When executionStrategyOnFieldValuesInfo is called, it increments completionFinished to 1, - // making level 2 ready (since 1==1 and level 1 is dispatched). - // But FIRST, simulate another thread having already dispatched level 2: - dispatchedLevels.add(2); - - // Now call the private markLevelAsDispatchedIfReady(2, callStack) via reflection. - // isLevelReady(2) will return true (level 1 dispatched, executeObjectCalls(0)==1, - // but completionFinished is 0, so we need to set it up). - // Actually, we need completionFinished == executeObjectCalls at level 0. - // Let's increment completionFinished by calling onCompletionFinished directly. - // But onCompletionFinished checks dispatchedLevels.contains(level+2) first. - // Since we added 2 to dispatchedLevels, onCompletionFinished(0) would break immediately. - - // Instead, set up the state directly: make level 0 have matching counts. - // We need to call executeObjectOnFieldValuesInfo at level 0 to increment completionFinished. - // But that also checks level 2... which we pre-added. So it would break. - - // The cleanest approach: remove level 2, call the public API to set up the state, - // then add level 2 back, then call markLevelAsDispatchedIfReady directly. - dispatchedLevels.remove(2); - - // Increment completionFinished at level 0 via executionStrategyOnFieldValuesInfo - // This will also trigger dispatch of level 2 through the normal path. - // Let's just set up the state via reflection instead. - - // Get the markLevelAsDispatchedIfReady method - Method markMethod = PerLevelDataLoaderDispatchStrategy.class - .getDeclaredMethod("markLevelAsDispatchedIfReady", int.class, callStack.getClass()); - markMethod.setAccessible(true); - - // Get isLevelReady to work: need completionFinished == executeObjectCalls at level 0 - // Level 0 currently has StateForLevel(completionFinished=0, executeObjectCalls=1) - // We need to set completionFinished=1. Use the get/tryUpdateLevel methods on callStack. - Method getMethod = callStack.getClass().getDeclaredMethod("get", int.class); - getMethod.setAccessible(true); - Object stateForLevel0 = getMethod.invoke(callStack, 0); - - Method increaseCompletionMethod = stateForLevel0.getClass() - .getDeclaredMethod("increaseHappenedCompletionFinishedCount"); - increaseCompletionMethod.setAccessible(true); - Object updatedState = increaseCompletionMethod.invoke(stateForLevel0); - - Method tryUpdateMethod = callStack.getClass() - .getDeclaredMethod("tryUpdateLevel", int.class, stateForLevel0.getClass(), stateForLevel0.getClass()); - tryUpdateMethod.setAccessible(true); - tryUpdateMethod.invoke(callStack, 0, stateForLevel0, updatedState); - - // Now level 0 has executeObjectCalls=1, completionFinished=1 → level 2 is ready. + // Set up level 0 state so isLevelReady(2) returns true: + // need completionFinished == executeObjectCalls at level 0 + PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel state0 = callStack.get(0); + PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel updated = state0.increaseHappenedCompletionFinishedCount(); + callStack.tryUpdateLevel(0, state0, updated); // First call: should return true (level 2 not yet dispatched) - boolean firstResult = (boolean) markMethod.invoke(strategy, 2, callStack); + boolean firstResult = strategy.markLevelAsDispatchedIfReady(2, callStack); assertTrue(firstResult, "First dispatch of level 2 should succeed"); assertTrue(dispatchedLevels.contains(2), "Level 2 should be in dispatchedLevels"); - // Second call: simulates another thread arriving — should return false (LINE 447) - boolean secondResult = (boolean) markMethod.invoke(strategy, 2, callStack); - assertFalse(secondResult, "Second dispatch of level 2 should return false (another thread already dispatched)"); + // Second call: simulates another thread arriving — should return false + boolean secondResult = strategy.markLevelAsDispatchedIfReady(2, callStack); + assertFalse(secondResult, "Second dispatch of level 2 should return false (already dispatched)"); } /** * Tests the concurrent race between two threads calling onCompletionFinished, - * both trying to dispatch the same level. Uses CountDownLatch to maximize - * the chance of the race occurring. + * both trying to dispatch the same level. */ @Test void concurrentOnCompletionFinished_racesToDispatchSameLevel() throws Exception { - // Set up with executeObjectCalls=2 at level 0 so that both threads - // see completionFinished==executeObjectCalls after both increment + // Set up with executeObjectCalls=2 at level 0 ExecutionStrategyParameters rootParams = paramsAtLevel(0); strategy.executionStrategy(executionContext, rootParams, 1); @@ -210,19 +141,9 @@ void concurrentOnCompletionFinished_racesToDispatchSameLevel() throws Exception (DataFetcher) env -> null, "value", (Supplier) () -> null); - // Now: level 0 has executeObjectCalls=2, completionFinished=0, level 1 is dispatched. - // Two threads calling executeObjectOnFieldValuesInfo (level 0) will both increment - // completionFinished. When both have incremented (to 2), isLevelReady(2) returns true - // for both, and they race to dispatchedLevels.add(2). - CountDownLatch startLatch = new CountDownLatch(1); - AtomicBoolean thread1Result = new AtomicBoolean(false); - AtomicBoolean thread2Result = new AtomicBoolean(false); - AtomicBoolean raceOccurred = new AtomicBoolean(false); - ExecutorService executor = Executors.newFixedThreadPool(2); - // Both threads will call executeObjectOnFieldValuesInfo at level 0 Runnable task = () -> { try { startLatch.await(); @@ -236,31 +157,20 @@ void concurrentOnCompletionFinished_racesToDispatchSameLevel() throws Exception executor.submit(task); executor.submit(task); - // Release both threads simultaneously startLatch.countDown(); executor.shutdown(); assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - // Access dispatchedLevels to verify level 2 was dispatched exactly once - Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); - callStackField.setAccessible(true); - Object callStack = callStackField.get(strategy); - Field dispatchedLevelsField = callStack.getClass().getDeclaredField("dispatchedLevels"); - dispatchedLevelsField.setAccessible(true); - @SuppressWarnings("unchecked") - Set dispatchedLevels = (Set) dispatchedLevelsField.get(callStack); - - // Level 2 should be dispatched (regardless of which thread won the race) + Set dispatchedLevels = strategy.initialCallStack.dispatchedLevels; assertTrue(dispatchedLevels.contains(2), "Level 2 should be dispatched after both completions"); } /** - * Tests executeObjectOnFieldValuesException — the error recovery path - * that is never triggered by integration tests. + * Tests executeObjectOnFieldValuesException — the error recovery path. */ @Test - void executeObjectOnFieldValuesException_callsOnCompletionFinished() throws Exception { + void executeObjectOnFieldValuesException_callsOnCompletionFinished() { ExecutionStrategyParameters rootParams = paramsAtLevel(0); strategy.executionStrategy(executionContext, rootParams, 1); @@ -270,33 +180,24 @@ void executeObjectOnFieldValuesException_callsOnCompletionFinished() throws Exce (DataFetcher) env -> null, "value", (Supplier) () -> null); - // Call the error handler — this should not throw and should call onCompletionFinished + // Call the error handler ExecutionStrategyParameters level2Params = paramsAtLevel(2); strategy.executeObjectOnFieldValuesException( new RuntimeException("test error"), level2Params); - // Verify it ran without error — the completion count at level 2 should have incremented - Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); - callStackField.setAccessible(true); - Object callStack = callStackField.get(strategy); - - Method getMethod = callStack.getClass().getDeclaredMethod("get", int.class); - getMethod.setAccessible(true); - Object stateForLevel2 = getMethod.invoke(callStack, 2); - - Field completionField = stateForLevel2.getClass().getDeclaredField("happenedCompletionFinishedCount"); - completionField.setAccessible(true); - int completionCount = (int) completionField.get(stateForLevel2); - assertTrue(completionCount > 0, + // Verify completion count at level 2 was incremented + PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel state = + strategy.initialCallStack.get(2); + assertTrue(state.happenedCompletionFinishedCount > 0, "completionFinished should have been incremented by the exception handler"); } /** * Tests executionStrategyOnFieldValuesException — the error recovery path - * at the top-level execution strategy that is never triggered by integration tests. + * at the top-level execution strategy. */ @Test - void executionStrategyOnFieldValuesException_callsOnCompletionFinished() throws Exception { + void executionStrategyOnFieldValuesException_callsOnCompletionFinished() { ExecutionStrategyParameters rootParams = paramsAtLevel(0); strategy.executionStrategy(executionContext, rootParams, 1); @@ -305,18 +206,9 @@ void executionStrategyOnFieldValuesException_callsOnCompletionFinished() throws new RuntimeException("test error"), rootParams); // Verify completion count at level 0 was incremented - Field callStackField = PerLevelDataLoaderDispatchStrategy.class.getDeclaredField("initialCallStack"); - callStackField.setAccessible(true); - Object callStack = callStackField.get(strategy); - - Method getMethod = callStack.getClass().getDeclaredMethod("get", int.class); - getMethod.setAccessible(true); - Object stateForLevel0 = getMethod.invoke(callStack, 0); - - Field completionField = stateForLevel0.getClass().getDeclaredField("happenedCompletionFinishedCount"); - completionField.setAccessible(true); - int completionCount = (int) completionField.get(stateForLevel0); - assertTrue(completionCount > 0, + PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel state = + strategy.initialCallStack.get(0); + assertTrue(state.happenedCompletionFinishedCount > 0, "completionFinished should have been incremented by the exception handler"); } } From 1bf69f17bf318da27edcfd47e87f533537e6ca22 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Mon, 9 Mar 2026 09:07:15 +1000 Subject: [PATCH 3/3] Rewrite tests as Spock specification Convert JUnit test to Groovy/Spock to match project conventions. Co-Authored-By: Claude Opus 4.6 --- ...LevelDataLoaderDispatchStrategyTest.groovy | 182 +++++++++++++++ ...erLevelDataLoaderDispatchStrategyTest.java | 214 ------------------ 2 files changed, 182 insertions(+), 214 deletions(-) create mode 100644 src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.groovy delete mode 100644 src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java diff --git a/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.groovy b/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.groovy new file mode 100644 index 0000000000..b67dc7e37b --- /dev/null +++ b/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.groovy @@ -0,0 +1,182 @@ +package graphql.execution.instrumentation.dataloader + +import graphql.EngineRunningState +import graphql.ExecutionInput +import graphql.GraphQLContext +import graphql.Profiler +import graphql.Scalars +import graphql.execution.AsyncExecutionStrategy +import graphql.execution.CoercedVariables +import graphql.execution.ExecutionContextBuilder +import graphql.execution.ExecutionId +import graphql.execution.ExecutionStepInfo +import graphql.execution.ExecutionStrategyParameters +import graphql.execution.MergedSelectionSet +import graphql.execution.NonNullableFieldValidator +import graphql.execution.ResultPath +import graphql.execution.ValueUnboxer +import graphql.execution.instrumentation.SimplePerformantInstrumentation +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import org.dataloader.DataLoaderRegistry +import spock.lang.Specification + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.function.Supplier + +import static graphql.StarWarsSchema.starWarsSchema + +/** + * Tests for concurrency-dependent code paths in {@link PerLevelDataLoaderDispatchStrategy} + * that are otherwise non-deterministically covered by integration tests. + */ +class PerLevelDataLoaderDispatchStrategyTest extends Specification { + + def executionContext + def strategy + + void setup() { + def dummyStrategy = new AsyncExecutionStrategy() + def ei = ExecutionInput.newExecutionInput("{ hero { name } }").build() + def builder = ExecutionContextBuilder.newExecutionContextBuilder() + .instrumentation(SimplePerformantInstrumentation.INSTANCE) + .executionId(ExecutionId.from("test")) + .graphQLSchema(starWarsSchema) + .queryStrategy(dummyStrategy) + .mutationStrategy(dummyStrategy) + .subscriptionStrategy(dummyStrategy) + .coercedVariables(CoercedVariables.emptyVariables()) + .graphQLContext(GraphQLContext.newContext().build()) + .executionInput(ei) + .root("root") + .dataLoaderRegistry(new DataLoaderRegistry()) + .locale(Locale.getDefault()) + .valueUnboxer(ValueUnboxer.DEFAULT) + .profiler(Profiler.NO_OP) + .engineRunningState(new EngineRunningState(ei, Profiler.NO_OP)) + executionContext = builder.build() + strategy = new PerLevelDataLoaderDispatchStrategy(executionContext) + } + + private ExecutionStrategyParameters paramsAtLevel(int level) { + def path = ResultPath.rootPath() + for (int i = 0; i < level; i++) { + path = path.segment("f" + i) + } + return ExecutionStrategyParameters.newParameters() + .executionStepInfo(ExecutionStepInfo.newExecutionStepInfo() + .type(Scalars.GraphQLString) + .path(path) + .build()) + .fields(MergedSelectionSet.newMergedSelectionSet().build()) + .nonNullFieldValidator(new NonNullableFieldValidator(executionContext)) + .path(path) + .build() + } + + def "markLevelAsDispatchedIfReady returns false when level already dispatched"() { + given: + def callStack = strategy.initialCallStack + def dispatchedLevels = callStack.dispatchedLevels + + and: "set up level 0 via executionStrategy and dispatch level 1 via fieldFetched" + def rootParams = paramsAtLevel(0) + strategy.executionStrategy(executionContext, rootParams, 1) + def level1Params = paramsAtLevel(1) + strategy.fieldFetched(executionContext, level1Params, + { env -> null } as DataFetcher, + "value", + { -> null } as Supplier) + + and: "make isLevelReady(2) return true by matching completionFinished to executeObjectCalls at level 0" + def state0 = callStack.get(0) + callStack.tryUpdateLevel(0, state0, state0.increaseHappenedCompletionFinishedCount()) + + expect: + dispatchedLevels.contains(1) + + when: "first dispatch of level 2" + def firstResult = strategy.markLevelAsDispatchedIfReady(2, callStack) + + then: + firstResult + dispatchedLevels.contains(2) + + when: "second dispatch of level 2 (simulates another thread arriving late)" + def secondResult = strategy.markLevelAsDispatchedIfReady(2, callStack) + + then: + !secondResult + } + + def "concurrent onCompletionFinished races to dispatch same level"() { + given: + def rootParams = paramsAtLevel(0) + strategy.executionStrategy(executionContext, rootParams, 1) + + and: "increment executeObjectCalls at level 0 from 1 to 2" + def level0Params = paramsAtLevel(0) + strategy.executeObject(executionContext, level0Params, 1) + + and: "dispatch level 1 via fieldFetched" + def level1Params = paramsAtLevel(1) + strategy.fieldFetched(executionContext, level1Params, + { env -> null } as DataFetcher, + "value", + { -> null } as Supplier) + + when: "two threads concurrently complete level 0" + def startLatch = new CountDownLatch(1) + def executor = Executors.newFixedThreadPool(2) + + def task = { + startLatch.await() + strategy.executeObjectOnFieldValuesInfo(Collections.emptyList(), level0Params) + } as Runnable + + executor.submit(task) + executor.submit(task) + startLatch.countDown() + executor.shutdown() + executor.awaitTermination(5, TimeUnit.SECONDS) + + then: "level 2 is dispatched exactly once (regardless of which thread won)" + strategy.initialCallStack.dispatchedLevels.contains(2) + } + + def "executeObjectOnFieldValuesException calls onCompletionFinished"() { + given: + def rootParams = paramsAtLevel(0) + strategy.executionStrategy(executionContext, rootParams, 1) + + and: "dispatch level 1 via fieldFetched" + def level1Params = paramsAtLevel(1) + strategy.fieldFetched(executionContext, level1Params, + { env -> null } as DataFetcher, + "value", + { -> null } as Supplier) + + when: + def level2Params = paramsAtLevel(2) + strategy.executeObjectOnFieldValuesException( + new RuntimeException("test error"), level2Params) + + then: + strategy.initialCallStack.get(2).happenedCompletionFinishedCount > 0 + } + + def "executionStrategyOnFieldValuesException calls onCompletionFinished"() { + given: + def rootParams = paramsAtLevel(0) + strategy.executionStrategy(executionContext, rootParams, 1) + + when: + strategy.executionStrategyOnFieldValuesException( + new RuntimeException("test error"), rootParams) + + then: + strategy.initialCallStack.get(0).happenedCompletionFinishedCount > 0 + } +} diff --git a/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java b/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java deleted file mode 100644 index 12de538f6d..0000000000 --- a/src/test/groovy/graphql/execution/instrumentation/dataloader/PerLevelDataLoaderDispatchStrategyTest.java +++ /dev/null @@ -1,214 +0,0 @@ -package graphql.execution.instrumentation.dataloader; - -import graphql.ExecutionInput; -import graphql.GraphQLContext; -import graphql.Profiler; -import graphql.execution.CoercedVariables; -import graphql.execution.ExecutionContext; -import graphql.execution.ExecutionContextBuilder; -import graphql.execution.ExecutionId; -import graphql.execution.ExecutionStepInfo; -import graphql.execution.ExecutionStrategy; -import graphql.execution.ExecutionStrategyParameters; -import graphql.execution.MergedSelectionSet; -import graphql.execution.NonNullableFieldValidator; -import graphql.execution.ResultPath; -import graphql.execution.ValueUnboxer; -import graphql.execution.instrumentation.SimplePerformantInstrumentation; -import graphql.schema.DataFetcher; -import graphql.schema.DataFetchingEnvironment; -import org.dataloader.DataLoaderRegistry; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Collections; -import java.util.Locale; -import java.util.Set; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - -import static graphql.StarWarsSchema.starWarsSchema; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Tests for concurrency-dependent code paths in {@link PerLevelDataLoaderDispatchStrategy} - * that are otherwise non-deterministically covered by integration tests. - */ -public class PerLevelDataLoaderDispatchStrategyTest { - - private ExecutionContext executionContext; - private PerLevelDataLoaderDispatchStrategy strategy; - - @BeforeEach - void setUp() { - ExecutionStrategy dummyStrategy = new graphql.execution.AsyncExecutionStrategy(); - ExecutionInput ei = ExecutionInput.newExecutionInput("{ hero { name } }").build(); - ExecutionContextBuilder builder = ExecutionContextBuilder.newExecutionContextBuilder() - .instrumentation(SimplePerformantInstrumentation.INSTANCE) - .executionId(ExecutionId.from("test")) - .graphQLSchema(starWarsSchema) - .queryStrategy(dummyStrategy) - .mutationStrategy(dummyStrategy) - .subscriptionStrategy(dummyStrategy) - .coercedVariables(CoercedVariables.emptyVariables()) - .graphQLContext(GraphQLContext.newContext().build()) - .executionInput(ei) - .root("root") - .dataLoaderRegistry(new DataLoaderRegistry()) - .locale(Locale.getDefault()) - .valueUnboxer(ValueUnboxer.DEFAULT) - .profiler(Profiler.NO_OP) - .engineRunningState(new graphql.EngineRunningState(ei, Profiler.NO_OP)); - executionContext = builder.build(); - strategy = new PerLevelDataLoaderDispatchStrategy(executionContext); - } - - private ExecutionStrategyParameters paramsAtLevel(int level) { - ResultPath path = ResultPath.rootPath(); - for (int i = 0; i < level; i++) { - path = path.segment("f" + i); - } - return ExecutionStrategyParameters.newParameters() - .executionStepInfo(ExecutionStepInfo.newExecutionStepInfo() - .type(graphql.Scalars.GraphQLString) - .path(path) - .build()) - .fields(MergedSelectionSet.newMergedSelectionSet().build()) - .nonNullFieldValidator(new NonNullableFieldValidator(executionContext)) - .path(path) - .build(); - } - - /** - * Tests that when two calls try to dispatch the same level, - * the second call correctly returns false from markLevelAsDispatchedIfReady. - */ - @Test - void markLevelAsDispatchedIfReady_returnsFalse_whenAlreadyDispatched() { - PerLevelDataLoaderDispatchStrategy.CallStack callStack = strategy.initialCallStack; - Set dispatchedLevels = callStack.dispatchedLevels; - - // Set up state through the public API: - // executionStrategy initializes level 0: executeObjectCalls=1, expectedFirstLevelFetchCount=1 - ExecutionStrategyParameters rootParams = paramsAtLevel(0); - strategy.executionStrategy(executionContext, rootParams, 1); - - // fieldFetched at level 1 dispatches level 1 - ExecutionStrategyParameters level1Params = paramsAtLevel(1); - strategy.fieldFetched(executionContext, level1Params, - (DataFetcher) env -> null, "value", - (Supplier) () -> null); - - assertTrue(dispatchedLevels.contains(1), "Level 1 should be dispatched after fieldFetched"); - - // Set up level 0 state so isLevelReady(2) returns true: - // need completionFinished == executeObjectCalls at level 0 - PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel state0 = callStack.get(0); - PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel updated = state0.increaseHappenedCompletionFinishedCount(); - callStack.tryUpdateLevel(0, state0, updated); - - // First call: should return true (level 2 not yet dispatched) - boolean firstResult = strategy.markLevelAsDispatchedIfReady(2, callStack); - assertTrue(firstResult, "First dispatch of level 2 should succeed"); - assertTrue(dispatchedLevels.contains(2), "Level 2 should be in dispatchedLevels"); - - // Second call: simulates another thread arriving — should return false - boolean secondResult = strategy.markLevelAsDispatchedIfReady(2, callStack); - assertFalse(secondResult, "Second dispatch of level 2 should return false (already dispatched)"); - } - - /** - * Tests the concurrent race between two threads calling onCompletionFinished, - * both trying to dispatch the same level. - */ - @Test - void concurrentOnCompletionFinished_racesToDispatchSameLevel() throws Exception { - // Set up with executeObjectCalls=2 at level 0 - ExecutionStrategyParameters rootParams = paramsAtLevel(0); - strategy.executionStrategy(executionContext, rootParams, 1); - - // Increment executeObjectCalls at level 0 from 1 to 2 - ExecutionStrategyParameters level0Params = paramsAtLevel(0); - strategy.executeObject(executionContext, level0Params, 1); - - // Dispatch level 1 via fieldFetched - ExecutionStrategyParameters level1Params = paramsAtLevel(1); - strategy.fieldFetched(executionContext, level1Params, - (DataFetcher) env -> null, "value", - (Supplier) () -> null); - - CountDownLatch startLatch = new CountDownLatch(1); - ExecutorService executor = Executors.newFixedThreadPool(2); - - Runnable task = () -> { - try { - startLatch.await(); - strategy.executeObjectOnFieldValuesInfo( - Collections.emptyList(), level0Params); - } catch (Exception e) { - throw new RuntimeException(e); - } - }; - - executor.submit(task); - executor.submit(task); - - startLatch.countDown(); - executor.shutdown(); - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); - - Set dispatchedLevels = strategy.initialCallStack.dispatchedLevels; - assertTrue(dispatchedLevels.contains(2), - "Level 2 should be dispatched after both completions"); - } - - /** - * Tests executeObjectOnFieldValuesException — the error recovery path. - */ - @Test - void executeObjectOnFieldValuesException_callsOnCompletionFinished() { - ExecutionStrategyParameters rootParams = paramsAtLevel(0); - strategy.executionStrategy(executionContext, rootParams, 1); - - // Dispatch level 1 via fieldFetched - ExecutionStrategyParameters level1Params = paramsAtLevel(1); - strategy.fieldFetched(executionContext, level1Params, - (DataFetcher) env -> null, "value", - (Supplier) () -> null); - - // Call the error handler - ExecutionStrategyParameters level2Params = paramsAtLevel(2); - strategy.executeObjectOnFieldValuesException( - new RuntimeException("test error"), level2Params); - - // Verify completion count at level 2 was incremented - PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel state = - strategy.initialCallStack.get(2); - assertTrue(state.happenedCompletionFinishedCount > 0, - "completionFinished should have been incremented by the exception handler"); - } - - /** - * Tests executionStrategyOnFieldValuesException — the error recovery path - * at the top-level execution strategy. - */ - @Test - void executionStrategyOnFieldValuesException_callsOnCompletionFinished() { - ExecutionStrategyParameters rootParams = paramsAtLevel(0); - strategy.executionStrategy(executionContext, rootParams, 1); - - // Call the error handler at the root level - strategy.executionStrategyOnFieldValuesException( - new RuntimeException("test error"), rootParams); - - // Verify completion count at level 0 was incremented - PerLevelDataLoaderDispatchStrategy.CallStack.StateForLevel state = - strategy.initialCallStack.get(0); - assertTrue(state.happenedCompletionFinishedCount > 0, - "completionFinished should have been incremented by the exception handler"); - } -}