Skip to content

Commit d427060

Browse files
authored
Chapter 35: Cache GraphQL responses per user (#11)
* Cache graphql responses per user * Propagate Correatlion ID with TaskExecutor * Reuse executor factory task executor bean * Pass MDC context with AsyncTaskExecutor
1 parent fdf0d13 commit d427060

22 files changed

+258
-156
lines changed

pom.xml

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<parent>
88
<groupId>org.springframework.boot</groupId>
99
<artifactId>spring-boot-starter-parent</artifactId>
10-
<version>2.3.3.RELEASE</version>
10+
<version>2.6.1</version>
1111
</parent>
1212
<groupId>org.example</groupId>
1313
<artifactId>learn-graphql-java</artifactId>
@@ -19,13 +19,21 @@
1919
<properties>
2020
<java.version>11</java.version>
2121
<pmd.version>3.11.0</pmd.version>
22-
<graphql.version>11.0.0</graphql.version>
22+
<graphql.version>12.0.0</graphql.version>
2323
<!-- 16.0.0 not on central -->
24-
<graphql.extended.scalars.version>15.0.0</graphql.extended.scalars.version>
24+
<graphql.extended.scalars.version>17.0</graphql.extended.scalars.version>
2525
<graphql.extended.validation.version>16.0.0</graphql.extended.validation.version>
2626
</properties>
2727

2828
<dependencies>
29+
<dependency>
30+
<groupId>org.springframework.boot</groupId>
31+
<artifactId>spring-boot-starter-web</artifactId>
32+
</dependency>
33+
<dependency>
34+
<groupId>com.github.ben-manes.caffeine</groupId>
35+
<artifactId>caffeine</artifactId>
36+
</dependency>
2937
<dependency>
3038
<groupId>com.graphql-java-kickstart</groupId>
3139
<artifactId>graphql-spring-boot-starter</artifactId>
@@ -66,18 +74,6 @@
6674
<artifactId>lombok</artifactId>
6775
<scope>provided</scope>
6876
</dependency>
69-
<dependency>
70-
<groupId>com.graphql-java-kickstart</groupId>
71-
<artifactId>playground-spring-boot-starter</artifactId>
72-
<version>${graphql.version}</version>
73-
<scope>runtime</scope>
74-
</dependency>
75-
<dependency>
76-
<groupId>com.graphql-java-kickstart</groupId>
77-
<artifactId>voyager-spring-boot-starter</artifactId>
78-
<version>${graphql.version}</version>
79-
<scope>runtime</scope>
80-
</dependency>
8177
<dependency>
8278
<groupId>io.projectreactor</groupId>
8379
<artifactId>reactor-core</artifactId>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.learn.graphql.cache;
2+
3+
import java.util.List;
4+
import lombok.Value;
5+
6+
@Value
7+
public class RequestKey {
8+
9+
String userId;
10+
List<String> queries;
11+
12+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.learn.graphql.cache;
2+
3+
import com.github.benmanes.caffeine.cache.Cache;
4+
import com.learn.graphql.config.security.GraphQLSecurityConfig;
5+
import graphql.kickstart.execution.input.GraphQLInvocationInput;
6+
import graphql.kickstart.servlet.cache.CachedResponse;
7+
import graphql.kickstart.servlet.cache.GraphQLResponseCacheManager;
8+
import javax.servlet.http.HttpServletRequest;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.stereotype.Component;
12+
13+
/**
14+
* Response Query Caching: Chapter 35
15+
*/
16+
@Slf4j
17+
@Component
18+
@RequiredArgsConstructor
19+
public class ResponseCacheManager implements GraphQLResponseCacheManager {
20+
21+
private final Cache<RequestKey, CachedResponse> responseCache;
22+
23+
@Override
24+
public CachedResponse get(HttpServletRequest request, GraphQLInvocationInput invocationInput) {
25+
return responseCache.getIfPresent(getRequestKey(request, invocationInput));
26+
}
27+
28+
@Override
29+
public boolean isCacheable(HttpServletRequest request, GraphQLInvocationInput invocationInput) {
30+
// Do not cache introspection query
31+
return invocationInput.getQueries()
32+
.stream()
33+
.noneMatch(this::isIntrospectionQuery);
34+
}
35+
36+
@Override
37+
public void put(HttpServletRequest request, GraphQLInvocationInput invocationInput,
38+
CachedResponse cachedResponse) {
39+
responseCache.put(getRequestKey(request, invocationInput), cachedResponse);
40+
}
41+
42+
private RequestKey getRequestKey(HttpServletRequest request,
43+
GraphQLInvocationInput invocationInput) {
44+
return new RequestKey(getUserId(request), invocationInput.getQueries());
45+
}
46+
47+
private String getUserId(HttpServletRequest request) {
48+
var userId = request.getHeader(GraphQLSecurityConfig.USER_ID_PRE_AUTH_HEADER);
49+
if (userId == null) {
50+
throw new IllegalArgumentException("User Id is null. Cannot read from ResponseCacheManager.");
51+
}
52+
return userId;
53+
}
54+
55+
private boolean isIntrospectionQuery(String query) {
56+
return query.contains("Introspection");
57+
}
58+
59+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.learn.graphql.config;
2+
3+
import com.learn.graphql.util.ExecutorFactory;
4+
import java.util.concurrent.Executor;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
8+
@Configuration
9+
public class AsyncExecutorConfig {
10+
11+
@Bean
12+
public Executor balanceExecutor(ExecutorFactory executorFactory) {
13+
return executorFactory.newExecutor();
14+
}
15+
16+
@Bean
17+
public Executor bankAccountExecutor(ExecutorFactory executorFactory) {
18+
return executorFactory.newExecutor();
19+
}
20+
21+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.learn.graphql.config;
2+
3+
import com.github.benmanes.caffeine.cache.Cache;
4+
import com.github.benmanes.caffeine.cache.Caffeine;
5+
import com.learn.graphql.cache.RequestKey;
6+
import graphql.kickstart.servlet.cache.CachedResponse;
7+
import java.time.Duration;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
12+
@Slf4j
13+
@Configuration
14+
public class CacheConfig {
15+
16+
@Bean
17+
public Cache<RequestKey, CachedResponse> responseCache() {
18+
return Caffeine.newBuilder()
19+
.expireAfterWrite(Duration.ofMinutes(1L))
20+
.maximumSize(100L)
21+
.removalListener((key, value, cause) ->
22+
log.info("Key {} with value {} was removed from the response cache. Cause {}",
23+
key, value, cause))
24+
.build();
25+
}
26+
27+
}

src/main/java/com/learn/graphql/config/ScalarConfig.java

Lines changed: 0 additions & 26 deletions
This file was deleted.

src/main/java/com/learn/graphql/config/security/AuthenticationConnectionListener.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package com.learn.graphql.config.security;
22

3+
import static com.learn.graphql.config.security.GraphQLSecurityConfig.CORRELATION_ID;
4+
35
import graphql.kickstart.execution.subscriptions.SubscriptionSession;
46
import graphql.kickstart.execution.subscriptions.apollo.ApolloSubscriptionConnectionListener;
57
import graphql.kickstart.execution.subscriptions.apollo.OperationMessage;
68
import java.util.Map;
9+
import java.util.UUID;
710
import lombok.extern.slf4j.Slf4j;
11+
import org.slf4j.MDC;
812
import org.springframework.security.core.Authentication;
913
import org.springframework.security.core.context.SecurityContextHolder;
1014
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
@@ -59,13 +63,15 @@ If a session has a frame available, the session will be passed to another thread
5963

6064
var token = new PreAuthenticatedAuthenticationToken(userId, null, grantedAuthorities);
6165
session.getUserProperties().put(AUTHENTICATION, token);
66+
session.getUserProperties().put(CORRELATION_ID, UUID.randomUUID().toString());
6267
}
6368

6469
@Override
6570
public void onStart(SubscriptionSession session, OperationMessage message) {
6671
log.info("onStart with payload {}", message.getPayload());
6772
var authentication = (Authentication) session.getUserProperties().get(AUTHENTICATION);
6873
SecurityContextHolder.getContext().setAuthentication(authentication);
74+
MDC.put(CORRELATION_ID, (String) session.getUserProperties().get(CORRELATION_ID));
6975
}
7076

7177
@Override
@@ -76,6 +82,7 @@ public void onStop(SubscriptionSession session, OperationMessage message) {
7682
@Override
7783
public void onTerminate(SubscriptionSession session, OperationMessage message) {
7884
log.info("onTerminate with payload {}", message.getPayload());
85+
MDC.clear();
7986
}
8087

8188
}

src/main/java/com/learn/graphql/config/security/GraphQLSecurityConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class GraphQLSecurityConfig extends WebSecurityConfigurerAdapter {
3737
*/
3838
public static final String USER_ID_PRE_AUTH_HEADER = "user_id";
3939
public static final String USER_ROLES_PRE_AUTH_HEADER = "user_roles";
40+
public static final String CORRELATION_ID = "correlation_id";
4041

4142
private final PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider;
4243

src/main/java/com/learn/graphql/context/CustomGraphQLContextBuilder.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
package com.learn.graphql.context;
22

3+
import static com.learn.graphql.config.security.GraphQLSecurityConfig.CORRELATION_ID;
4+
35
import com.learn.graphql.context.dataloader.DataLoaderRegistryFactory;
46
import graphql.kickstart.execution.context.GraphQLContext;
57
import graphql.kickstart.servlet.context.DefaultGraphQLServletContext;
68
import graphql.kickstart.servlet.context.DefaultGraphQLWebSocketContext;
79
import graphql.kickstart.servlet.context.GraphQLServletContextBuilder;
10+
import java.util.UUID;
811
import javax.servlet.http.HttpServletRequest;
912
import javax.servlet.http.HttpServletResponse;
1013
import javax.websocket.Session;
1114
import javax.websocket.server.HandshakeRequest;
1215
import lombok.RequiredArgsConstructor;
1316
import lombok.extern.slf4j.Slf4j;
17+
import org.slf4j.MDC;
1418
import org.springframework.stereotype.Component;
1519

1620
@Slf4j
@@ -24,6 +28,8 @@ public class CustomGraphQLContextBuilder implements GraphQLServletContextBuilder
2428
public GraphQLContext build(HttpServletRequest httpServletRequest,
2529
HttpServletResponse httpServletResponse) {
2630

31+
MDC.put(CORRELATION_ID, UUID.randomUUID().toString());
32+
2733
var userId = httpServletRequest.getHeader("user_id");
2834

2935
var context = DefaultGraphQLServletContext.createServletContext()

src/main/java/com/learn/graphql/context/dataloader/DataLoaderRegistryFactory.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.learn.graphql.context.dataloader;
22

33
import com.learn.graphql.service.BalanceService;
4-
import com.learn.graphql.util.ExecutorFactory;
54
import java.math.BigDecimal;
65
import java.util.Map;
76
import java.util.Set;
@@ -18,10 +17,10 @@
1817
@RequiredArgsConstructor
1918
public class DataLoaderRegistryFactory {
2019

21-
private final BalanceService balanceService;
22-
2320
public static final String BALANCE_DATA_LOADER = "BALANCE_DATA_LOADER";
24-
private static final Executor balanceThreadPool = ExecutorFactory.newExecutor();
21+
22+
private final BalanceService balanceService;
23+
private final Executor balanceExecutor;
2524

2625
public DataLoaderRegistry create(String userId) {
2726
var registry = new DataLoaderRegistry();
@@ -34,7 +33,7 @@ private DataLoader<UUID, BigDecimal> createBalanceDataLoader(String userId) {
3433
.newMappedDataLoader((Set<UUID> bankAccountIds, BatchLoaderEnvironment environment) ->
3534
CompletableFuture.supplyAsync(() ->
3635
balanceService.getBalanceFor((Map) environment.getKeyContexts(), userId),
37-
balanceThreadPool));
36+
balanceExecutor));
3837
}
3938

4039
}

src/main/java/com/learn/graphql/instrumentation/RequestLoggingInstrumentation.java

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,29 @@
1010
import java.time.Instant;
1111
import lombok.RequiredArgsConstructor;
1212
import lombok.extern.slf4j.Slf4j;
13-
import org.slf4j.MDC;
1413
import org.springframework.stereotype.Component;
1514

1615
@Slf4j
1716
@Component
1817
@RequiredArgsConstructor
1918
public class RequestLoggingInstrumentation extends SimpleInstrumentation {
2019

21-
public static final String CORRELATION_ID = "correlation_id";
22-
2320
private final Clock clock;
2421

2522
@Override
2623
public InstrumentationContext<ExecutionResult> beginExecution(
2724
InstrumentationExecutionParameters parameters) {
2825
var start = Instant.now(clock);
29-
// Add the correlation ID to the NIO thread
30-
MDC.put(CORRELATION_ID, parameters.getExecutionInput().getExecutionId().toString());
31-
3226
log.info("Query: {} with variables: {}", parameters.getQuery(), parameters.getVariables());
3327
return SimpleInstrumentationContext.whenCompleted((executionResult, throwable) -> {
28+
// This callback will occur in the resolver thread.
29+
3430
var duration = Duration.between(start, Instant.now(clock));
3531
if (throwable == null) {
3632
log.info("Completed successfully in: {}", duration);
3733
} else {
3834
log.warn("Failed in: {}", duration, throwable);
3935
}
40-
// If we have async resolvers, this callback can occur in the thread-pool and not the NIO thread.
41-
// In that case, the LoggingListener will be used as a fallback to clear the NIO thread.
42-
MDC.clear();
4336
});
4437
}
4538

src/main/java/com/learn/graphql/listener/LoggingListener.java

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,27 @@
33
import graphql.kickstart.servlet.core.GraphQLServletListener;
44
import javax.servlet.http.HttpServletRequest;
55
import javax.servlet.http.HttpServletResponse;
6-
import lombok.RequiredArgsConstructor;
76
import lombok.extern.slf4j.Slf4j;
8-
import org.slf4j.MDC;
97
import org.springframework.stereotype.Component;
108

119
@Slf4j
1210
@Component
13-
@RequiredArgsConstructor
1411
public class LoggingListener implements GraphQLServletListener {
1512

13+
/**
14+
* Override other default methods to provide GraphQL life-cycle callbacks.
15+
*/
16+
private static final RequestCallback ON_FINALLY_LISTENER = new RequestCallback() {
17+
@Override
18+
public void onFinally(HttpServletRequest request, HttpServletResponse response) {
19+
// The final callback in the GraphQL life-cycle
20+
log.info("OnFinally: GraphQL query complete");
21+
}
22+
};
23+
1624
@Override
1725
public RequestCallback onRequest(HttpServletRequest request, HttpServletResponse response) {
18-
return new RequestCallback() {
19-
@Override
20-
public void onSuccess(HttpServletRequest request, HttpServletResponse response) {
21-
// no-op
22-
}
23-
24-
@Override
25-
public void onError(HttpServletRequest request, HttpServletResponse response,
26-
Throwable throwable) {
27-
log.error("Caught exception in listener.", throwable);
28-
}
29-
30-
@Override
31-
public void onFinally(HttpServletRequest request, HttpServletResponse response) {
32-
// This callback will be called post graphql lifecycle.
33-
// If we are multi-threading we can clear the original NIO thread MDC variables here.
34-
MDC.clear();
35-
}
36-
};
26+
return ON_FINALLY_LISTENER;
3727
}
3828

3929
}

0 commit comments

Comments
 (0)