Skip to content

Commit 288b0c9

Browse files
authored
Merge pull request #2200 from Netflix/feature/reload-data-loader
Feature: Add data loader reload API for development environments
2 parents 59bada3 + 2101d0b commit 288b0c9

File tree

15 files changed

+1468
-394
lines changed

15 files changed

+1468
-394
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2025 Netflix, 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.netflix.graphql.dgs.springgraphql.conditions;
18+
19+
import org.springframework.context.annotation.Conditional;
20+
21+
import java.lang.annotation.Documented;
22+
import java.lang.annotation.ElementType;
23+
import java.lang.annotation.Retention;
24+
import java.lang.annotation.RetentionPolicy;
25+
import java.lang.annotation.Target;
26+
27+
28+
/**
29+
* Conditional that matches when it is established that the DGS Framework should consider reloading components at runtime.
30+
* Consider that reloading components should only be enabled in <i>local</i> and/or <i>development</i> since
31+
* call latencies will increase significantly while _reloading_ of the components is happening.
32+
*/
33+
34+
@Target({ ElementType.TYPE, ElementType.METHOD })
35+
@Retention(RetentionPolicy.RUNTIME)
36+
@Documented
37+
@Conditional(OnDgsReloadCondition.class)
38+
public @interface ConditionalOnDgsReload {
39+
}

graphql-dgs-spring-graphql/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
requires static spring.webflux;
1010
requires kotlin.stdlib;
1111
requires org.slf4j;
12+
requires spring.core;
1213

1314

1415
exports com.netflix.graphql.dgs.autoconfig to kotlin.reflect, spring.beans, spring.core;

graphql-dgs-spring-graphql/src/main/kotlin/com/netflix/graphql/dgs/springgraphql/autoconfig/DgsSpringGraphQLAutoConfiguration.kt

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.netflix.graphql.dgs.DgsComponent
2121
import com.netflix.graphql.dgs.DgsDataLoaderCustomizer
2222
import com.netflix.graphql.dgs.DgsDataLoaderInstrumentation
2323
import com.netflix.graphql.dgs.DgsDataLoaderOptionsProvider
24+
import com.netflix.graphql.dgs.DgsDataLoaderReloadController
2425
import com.netflix.graphql.dgs.DgsDefaultPreparsedDocumentProvider
2526
import com.netflix.graphql.dgs.DgsExecutionResult
2627
import com.netflix.graphql.dgs.DgsFederationResolver
@@ -38,6 +39,8 @@ import com.netflix.graphql.dgs.context.GraphQLContextContributorInstrumentation
3839
import com.netflix.graphql.dgs.exceptions.DefaultDataFetcherExceptionHandler
3940
import com.netflix.graphql.dgs.internal.DataFetcherResultProcessor
4041
import com.netflix.graphql.dgs.internal.DefaultDataLoaderOptionsProvider
42+
import com.netflix.graphql.dgs.internal.DefaultDgsDataLoaderProvider
43+
import com.netflix.graphql.dgs.internal.DefaultDgsDataLoaderReloadController
4144
import com.netflix.graphql.dgs.internal.DefaultDgsGraphQLContextBuilder
4245
import com.netflix.graphql.dgs.internal.DgsDataLoaderInstrumentationDataLoaderCustomizer
4346
import com.netflix.graphql.dgs.internal.DgsDataLoaderProvider
@@ -50,6 +53,7 @@ import com.netflix.graphql.dgs.internal.FluxDataFetcherResultProcessor
5053
import com.netflix.graphql.dgs.internal.GraphQLJavaErrorInstrumentation
5154
import com.netflix.graphql.dgs.internal.MonoDataFetcherResultProcessor
5255
import com.netflix.graphql.dgs.internal.QueryValueCustomizer
56+
import com.netflix.graphql.dgs.internal.ReloadableDgsDataLoaderProvider
5357
import com.netflix.graphql.dgs.internal.method.ArgumentResolver
5458
import com.netflix.graphql.dgs.internal.method.MethodDataFetcherFactory
5559
import com.netflix.graphql.dgs.mvc.internal.method.HandlerMethodArgumentResolverAdapter
@@ -61,6 +65,8 @@ import com.netflix.graphql.dgs.springgraphql.DgsGraphQLSourceBuilder
6165
import com.netflix.graphql.dgs.springgraphql.ReloadableGraphQLSource
6266
import com.netflix.graphql.dgs.springgraphql.SpringGraphQLDgsQueryExecutor
6367
import com.netflix.graphql.dgs.springgraphql.SpringGraphQLDgsReactiveQueryExecutor
68+
import com.netflix.graphql.dgs.springgraphql.conditions.ConditionalOnDgsReload
69+
import com.netflix.graphql.dgs.springgraphql.conditions.OnDgsReloadCondition
6470
import com.netflix.graphql.dgs.springgraphql.webflux.DgsWebFluxGraphQLInterceptor
6571
import com.netflix.graphql.dgs.springgraphql.webmvc.DgsWebMvcGraphQLInterceptor
6672
import graphql.GraphQLError
@@ -100,6 +106,7 @@ import org.springframework.boot.system.JavaVersion
100106
import org.springframework.context.ApplicationContext
101107
import org.springframework.context.annotation.Bean
102108
import org.springframework.context.annotation.Configuration
109+
import org.springframework.context.annotation.Primary
103110
import org.springframework.core.DefaultParameterNameDiscoverer
104111
import org.springframework.core.Ordered
105112
import org.springframework.core.PriorityOrdered
@@ -144,7 +151,7 @@ import java.util.function.Consumer
144151
import java.util.stream.Collectors
145152

146153
/**
147-
* Framework auto configuration based on open source Spring only, without Netflix integrations.
154+
* Framework autoconfiguration based on open source Spring only, without Netflix integrations.
148155
* This does NOT have logging, tracing, metrics and security integration.
149156
*/
150157
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
@@ -221,16 +228,82 @@ open class DgsSpringGraphQLAutoConfiguration(
221228
extensionProviders: List<DataLoaderInstrumentationExtensionProvider>,
222229
customizers: List<DgsDataLoaderCustomizer>,
223230
): DgsDataLoaderProvider =
224-
DgsDataLoaderProvider(
231+
DefaultDgsDataLoaderProvider(
225232
applicationContext = applicationContext,
226233
extensionProviders = extensionProviders,
234+
customizers = customizers,
227235
dataLoaderOptionsProvider = dataloaderOptionProvider,
228236
scheduledExecutorService = dgsScheduledExecutorService,
229237
scheduleDuration = dataloaderConfigProps.scheduleDuration,
230238
enableTickerMode = dataloaderConfigProps.tickerModeEnabled,
231-
customizers = customizers,
232239
)
233240

241+
/**
242+
* Autoconfiguration for DGS Data Loader reloading.
243+
*
244+
* This configuration is only activated when the 'dgs.reload' property is set to `true`.
245+
* It provides the necessary beans for data loader reloading, including:
246+
* - [ReloadableDgsDataLoaderProvider] that wraps the default provider implementation, the [DefaultDgsDataLoaderProvider].
247+
* - An implementation of a [DgsDataLoaderReloadController] that can be used ot force reloading of _Data Loaders_.
248+
*
249+
* **The reloading functionality is designed to be used primarily in development**,
250+
* it is discouraged to be used in production.
251+
*/
252+
@AutoConfiguration
253+
@ConditionalOnDgsReload
254+
open class DgsDataLoaderReloadAutoConfiguration(
255+
private val dataloaderConfigProps: DgsDataloaderConfigurationProperties,
256+
) {
257+
/**
258+
* Creates a [ReloadableDgsDataLoaderProvider] that wraps the standard [DgsDataLoaderProvider].
259+
*
260+
* This provider supports dynamic reloading of data loaders based on the [DgsReloadDataLoadersIndicator].
261+
* It maintains the same interface as the standard provider but adds reload capabilities.
262+
*
263+
* The `@Primary` annotation ensures this bean takes precedence over the standard `DgsDataLoaderProvider`
264+
* when reload functionality is enabled.
265+
*
266+
*/
267+
@Bean
268+
@Primary
269+
open fun reloadableDgsDataLoaderProvider(
270+
applicationContext: ApplicationContext,
271+
dataLoaderOptionProvider: DgsDataLoaderOptionsProvider,
272+
@Qualifier("dgsScheduledExecutorService") dgsScheduledExecutorService: ScheduledExecutorService,
273+
extensionProviders: List<DataLoaderInstrumentationExtensionProvider>,
274+
customizers: List<DgsDataLoaderCustomizer>,
275+
): DgsDataLoaderProvider {
276+
LOG.info("Creating reloadable data loader provider with reload support enabled")
277+
return ReloadableDgsDataLoaderProvider(
278+
applicationContext = applicationContext,
279+
extensionProviders = extensionProviders,
280+
customizers = customizers,
281+
dataLoaderOptionsProvider = dataLoaderOptionProvider,
282+
scheduledExecutorService = dgsScheduledExecutorService,
283+
scheduleDuration = dataloaderConfigProps.scheduleDuration,
284+
enableTickerMode = dataloaderConfigProps.tickerModeEnabled,
285+
)
286+
}
287+
288+
/**
289+
* Creates the default data loader reload controller.
290+
*
291+
* This controller provides a programmatic API for triggering data loader reloads
292+
* and accessing reload statistics. It's only available when reload functionality is enabled.
293+
*
294+
* @return DgsDataLoaderReloadController instance
295+
*/
296+
@Bean
297+
@ConditionalOnMissingBean
298+
open fun dgsDataLoaderReloadController(
299+
reloadableDgsDataLoaderProvider: ReloadableDgsDataLoaderProvider,
300+
): DgsDataLoaderReloadController {
301+
LOG.info("Creating data loader reload controller")
302+
// Get the actual ReloadableDgsDataLoaderProvider instance from the context
303+
return DefaultDgsDataLoaderReloadController(reloadableDgsDataLoaderProvider)
304+
}
305+
}
306+
234307
@Bean
235308
open fun entityFetcherRegistry(): EntityFetcherRegistry = EntityFetcherRegistry()
236309

@@ -272,9 +345,7 @@ open class DgsSpringGraphQLAutoConfiguration(
272345
@Bean
273346
@ConditionalOnMissingBean
274347
open fun defaultReloadSchemaIndicator(environment: Environment): ReloadSchemaIndicator {
275-
val isLaptopProfile = environment.activeProfiles.contains("laptop")
276-
val hotReloadSetting = environment.getProperty("dgs.reload", Boolean::class.java, isLaptopProfile)
277-
348+
val hotReloadSetting = OnDgsReloadCondition.evaluate(environment)
278349
return ReloadSchemaIndicator {
279350
hotReloadSetting
280351
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2025 Netflix, 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.netflix.graphql.dgs.springgraphql.conditions
18+
19+
import org.springframework.boot.autoconfigure.condition.ConditionOutcome
20+
import org.springframework.boot.autoconfigure.condition.SpringBootCondition
21+
import org.springframework.context.annotation.ConditionContext
22+
import org.springframework.core.env.Environment
23+
import org.springframework.core.type.AnnotatedTypeMetadata
24+
import kotlin.collections.contains
25+
26+
class OnDgsReloadCondition : SpringBootCondition() {
27+
companion object {
28+
/**
29+
* `true`, if the _DGS Reload flag_ is enabled.
30+
*/
31+
fun evaluate(environment: Environment): Boolean {
32+
val isLaptopProfile = environment.activeProfiles.contains("laptop")
33+
val reloadEnabled = environment.getProperty("dgs.reload", Boolean::class.java, isLaptopProfile)
34+
return reloadEnabled
35+
}
36+
}
37+
38+
override fun getMatchOutcome(
39+
context: ConditionContext?,
40+
metadata: AnnotatedTypeMetadata?,
41+
): ConditionOutcome? {
42+
val environment = context!!.environment
43+
val reloadEnabled = evaluate(environment)
44+
return if (reloadEnabled) {
45+
ConditionOutcome.match("DgsReload enabled.")
46+
} else {
47+
ConditionOutcome.noMatch("DgsReload disabled")
48+
}
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2025 Netflix, 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.netflix.graphql.dgs.springgraphql
18+
19+
import com.netflix.graphql.dgs.internal.DefaultDgsDataLoaderReloadController
20+
import com.netflix.graphql.dgs.internal.ReloadableDgsDataLoaderProvider
21+
import io.mockk.every
22+
import io.mockk.mockk
23+
import io.mockk.verify
24+
import org.junit.jupiter.api.Assertions.assertEquals
25+
import org.junit.jupiter.api.Assertions.assertFalse
26+
import org.junit.jupiter.api.Assertions.assertTrue
27+
import org.junit.jupiter.api.Test
28+
import org.junit.jupiter.api.assertNotNull
29+
import org.junit.jupiter.api.assertNull
30+
31+
class DefaultDgsDataLoaderReloadControllerTest {
32+
@Test
33+
fun `should successfully reload data loaders`() {
34+
val mockProvider = mockk<ReloadableDgsDataLoaderProvider>()
35+
every { mockProvider.forceReload() } returns true
36+
37+
val controller = DefaultDgsDataLoaderReloadController(mockProvider)
38+
39+
val result = controller.reloadDataLoaders()
40+
41+
assertTrue(result)
42+
assertNotNull(controller.getLastReloadTime())
43+
verify(exactly = 1) { mockProvider.forceReload() }
44+
}
45+
46+
@Test
47+
fun `should handle reload failures gracefully`() {
48+
val mockProvider = mockk<ReloadableDgsDataLoaderProvider>()
49+
every { mockProvider.forceReload() } returns false
50+
51+
val controller = DefaultDgsDataLoaderReloadController(mockProvider)
52+
53+
val result = controller.reloadDataLoaders()
54+
55+
assertFalse(result)
56+
// Last reload time should not be set on failure
57+
assertNull(controller.getLastReloadTime())
58+
verify(exactly = 1) { mockProvider.forceReload() }
59+
}
60+
61+
@Test
62+
fun `should handle provider exceptions`() {
63+
val mockProvider = mockk<ReloadableDgsDataLoaderProvider>()
64+
every { mockProvider.forceReload() } throws RuntimeException("Test exception")
65+
66+
val controller = DefaultDgsDataLoaderReloadController(mockProvider)
67+
68+
val result = controller.reloadDataLoaders()
69+
70+
assertFalse(result)
71+
assertNull(controller.getLastReloadTime())
72+
verify(exactly = 1) { mockProvider.forceReload() }
73+
}
74+
75+
@Test
76+
fun `should always report reload as enabled`() {
77+
val mockProvider = mockk<ReloadableDgsDataLoaderProvider>()
78+
val controller = DefaultDgsDataLoaderReloadController(mockProvider)
79+
80+
assertTrue(controller.isReloadEnabled())
81+
}
82+
83+
@Test
84+
fun `should track reload statistics`() {
85+
val mockProvider = mockk<ReloadableDgsDataLoaderProvider>()
86+
every { mockProvider.forceReload() } returns true
87+
88+
val controller = DefaultDgsDataLoaderReloadController(mockProvider)
89+
90+
// Initial stats
91+
val initialStats = controller.getReloadStats()
92+
assertEquals(0L, initialStats.totalReloads)
93+
assertNull(initialStats.lastReloadTime)
94+
assertNull(initialStats.lastReloadDuration)
95+
assertTrue(initialStats.isEnabled)
96+
97+
// Perform some reloads
98+
controller.reloadDataLoaders()
99+
Thread.sleep(1) // Ensure different timestamp
100+
controller.reloadDataLoaders()
101+
102+
val finalStats = controller.getReloadStats()
103+
assertEquals(2L, finalStats.totalReloads)
104+
assertNotNull(finalStats.lastReloadTime)
105+
assertNotNull(finalStats.lastReloadDuration)
106+
assertTrue(finalStats.isEnabled)
107+
108+
verify(exactly = 2) { mockProvider.forceReload() }
109+
}
110+
111+
@Test
112+
fun `should update last reload time only on success`() {
113+
val mockProvider = mockk<ReloadableDgsDataLoaderProvider>()
114+
val controller = DefaultDgsDataLoaderReloadController(mockProvider)
115+
116+
// First reload succeeds
117+
every { mockProvider.forceReload() } returns true
118+
controller.reloadDataLoaders()
119+
val firstReloadTime = controller.getLastReloadTime()
120+
assertNotNull(firstReloadTime)
121+
122+
Thread.sleep(1) // Ensure different timestamp
123+
124+
// Second reload fails
125+
every { mockProvider.forceReload() } returns false
126+
controller.reloadDataLoaders()
127+
val secondReloadTime = controller.getLastReloadTime()
128+
129+
// Time should remain the same as the first successful reload
130+
assertEquals(firstReloadTime, secondReloadTime)
131+
132+
val stats = controller.getReloadStats()
133+
assertEquals(1L, stats.totalReloads) // Only one successful reload
134+
}
135+
}

0 commit comments

Comments
 (0)