Skip to content

Commit 90f18d3

Browse files
samuelAndalonsamvazquez
andauthored
feat:concurrent graphql server (#1443)
* feat: cherry pick concurrent graphql server * feat: remove unused imports Co-authored-by: samvazquez <[email protected]>
1 parent 5b058bf commit 90f18d3

File tree

11 files changed

+222
-12
lines changed

11 files changed

+222
-12
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ subprojects {
9797
// even though we don't have any Java code, since we are building using Java LTS version,
9898
// this is required for Gradle to set the correct JVM versions in the module metadata
9999
targetCompatibility = JavaVersion.VERSION_1_8
100+
sourceCompatibility = JavaVersion.VERSION_1_8
100101
}
101102

102103
// published artifacts

gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ mockkVersion = 1.12.0
4040
mustacheVersion = 0.9.10
4141
rxjavaVersion = 3.1.0
4242
wireMockVersion = 2.30.1
43+
kotlinxBenchmarkVersion = 0.4.0
4344

4445
# plugin versions
4546
detektVersion = 1.18.0

servers/graphql-kotlin-server/build.gradle.kts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,40 @@
1+
import kotlinx.benchmark.gradle.JvmBenchmarkTarget
2+
13
description = "Common code for running a GraphQL server in any HTTP server framework"
24

35
val kotlinCoroutinesVersion: String by project
6+
val kotlinxBenchmarkVersion: String by project
7+
8+
plugins {
9+
id("org.jetbrains.kotlinx.benchmark")
10+
}
411

512
dependencies {
613
api(project(path = ":graphql-kotlin-schema-generator"))
714
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutinesVersion")
815
}
916

17+
// Benchmarks
18+
19+
// Create a separate source set for benchmarks.
20+
sourceSets.create("benchmarks")
21+
22+
kotlin.sourceSets.getByName("benchmarks") {
23+
dependencies {
24+
implementation("org.jetbrains.kotlinx:kotlinx-benchmark-runtime:$kotlinxBenchmarkVersion")
25+
implementation(sourceSets.main.get().output)
26+
implementation(sourceSets.main.get().runtimeClasspath)
27+
}
28+
}
29+
30+
benchmark {
31+
targets {
32+
register("benchmarks") {
33+
this as JvmBenchmarkTarget
34+
}
35+
}
36+
}
37+
1038
tasks {
1139
jacocoTestCoverageVerification {
1240
violationRules {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2022 Expedia, 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+
* https://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.expediagroup.graphql.server
18+
19+
import com.expediagroup.graphql.server.extensions.isMutation
20+
import com.expediagroup.graphql.server.types.GraphQLRequest
21+
import org.openjdk.jmh.annotations.Benchmark
22+
import org.openjdk.jmh.annotations.Setup
23+
import org.openjdk.jmh.annotations.State
24+
import org.openjdk.jmh.annotations.Scope
25+
import org.openjdk.jmh.annotations.Fork
26+
import org.openjdk.jmh.annotations.Warmup
27+
import org.openjdk.jmh.annotations.Measurement
28+
import java.util.concurrent.TimeUnit
29+
import kotlin.random.Random
30+
31+
@State(Scope.Benchmark)
32+
@Fork(1)
33+
@Warmup(iterations = 2)
34+
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
35+
open class GraphQLRequestBenchmark {
36+
private val requests = mutableListOf<GraphQLRequest>()
37+
38+
@Setup
39+
fun setUp() {
40+
val charPool = ('a'..'z') + ('A'..'Z') + ('0'..'9')
41+
val range = (1..3072)
42+
repeat(50) {
43+
val randomStringForQuery = range
44+
.map { Random.nextInt(0, charPool.size) }
45+
.map(charPool::get)
46+
.joinToString("")
47+
val query = """$randomStringForQuery query HeroNameAndFriends(${"$"}episode: Episode) {
48+
hero(episode: ${"$"}episode) {
49+
name
50+
friends {
51+
name
52+
}
53+
}
54+
}"""
55+
requests.add(GraphQLRequest(query))
56+
}
57+
val randomStringForMutation = range
58+
.map { Random.nextInt(0, charPool.size) }
59+
.map(charPool::get)
60+
.joinToString("")
61+
62+
val mutation = """$randomStringForMutation mutation AddNewPet (${"$"}name: String!,${"$"}petType: PetType) {
63+
addPet(name:${"$"}name,petType:${"$"}petType) {
64+
name
65+
petType
66+
}
67+
}"""
68+
requests.add(GraphQLRequest(mutation))
69+
}
70+
71+
@Benchmark
72+
fun isMutationBenchmark(): Boolean {
73+
return requests.any(GraphQLRequest::isMutation)
74+
}
75+
}

servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/execution/GraphQLServer.kt

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@
1616

1717
package com.expediagroup.graphql.server.execution
1818

19+
import com.expediagroup.graphql.generator.execution.GraphQLContext
20+
import com.expediagroup.graphql.server.extensions.isMutation
21+
import com.expediagroup.graphql.server.extensions.toGraphQLError
22+
import com.expediagroup.graphql.server.extensions.toGraphQLKotlinType
1923
import com.expediagroup.graphql.server.types.GraphQLBatchRequest
2024
import com.expediagroup.graphql.server.types.GraphQLBatchResponse
2125
import com.expediagroup.graphql.server.types.GraphQLRequest
26+
import com.expediagroup.graphql.server.types.GraphQLResponse
2227
import com.expediagroup.graphql.server.types.GraphQLServerResponse
28+
import kotlinx.coroutines.async
29+
import kotlinx.coroutines.awaitAll
30+
import kotlinx.coroutines.supervisorScope
2331

2432
/**
2533
* A basic server implementation that parses the incoming request and returns a [GraphQLResponse].
@@ -48,14 +56,44 @@ open class GraphQLServer<Request>(
4856

4957
when (graphQLRequest) {
5058
is GraphQLRequest -> requestHandler.executeRequest(graphQLRequest, context, graphQLContext)
51-
is GraphQLBatchRequest -> GraphQLBatchResponse(
52-
graphQLRequest.requests.map {
53-
requestHandler.executeRequest(it, context, graphQLContext)
59+
is GraphQLBatchRequest -> when {
60+
graphQLRequest.requests.any(GraphQLRequest::isMutation) -> GraphQLBatchResponse(
61+
graphQLRequest.requests.map {
62+
requestHandler.executeRequest(it, context, graphQLContext)
63+
}
64+
)
65+
else -> {
66+
GraphQLBatchResponse(
67+
handleConcurrently(graphQLRequest, context, graphQLContext)
68+
)
5469
}
55-
)
70+
}
5671
}
5772
} else {
5873
null
5974
}
6075
}
76+
77+
/**
78+
* Concurrently execute a [batchRequest], a failure in an specific request will not cause the scope
79+
* to fail and does not affect the other requests. The total execution time will be the time of the slowest
80+
* request
81+
*/
82+
private suspend fun handleConcurrently(
83+
batchRequest: GraphQLBatchRequest,
84+
context: GraphQLContext?,
85+
graphQLContext: Map<*, Any>?
86+
): List<GraphQLResponse<*>> = supervisorScope {
87+
batchRequest.requests.map { request ->
88+
async {
89+
try {
90+
requestHandler.executeRequest(request, context, graphQLContext)
91+
} catch (e: Exception) {
92+
GraphQLResponse<Any?>(
93+
errors = listOf(e.toGraphQLError().toGraphQLKotlinType())
94+
)
95+
}
96+
}
97+
}.awaitAll()
98+
}
6199
}

servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/extensions/requestExtensions.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import org.dataloader.DataLoaderRegistry
2323
/**
2424
* Convert the common [GraphQLRequest] to the execution input used by graphql-java
2525
*/
26-
fun GraphQLRequest.toExecutionInput(graphQLContext: Any? = null, dataLoaderRegistry: DataLoaderRegistry? = null, graphQLContextMap: Map<*, Any>? = null): ExecutionInput =
26+
fun GraphQLRequest.toExecutionInput(
27+
graphQLContext: Any? = null,
28+
dataLoaderRegistry: DataLoaderRegistry? = null,
29+
graphQLContextMap: Map<*, Any>? = null
30+
): ExecutionInput =
2731
ExecutionInput.newExecutionInput()
2832
.query(this.query)
2933
.operationName(this.operationName)
@@ -34,3 +38,5 @@ fun GraphQLRequest.toExecutionInput(graphQLContext: Any? = null, dataLoaderRegis
3438
}
3539
.dataLoaderRegistry(dataLoaderRegistry ?: DataLoaderRegistry())
3640
.build()
41+
42+
fun GraphQLRequest.isMutation(): Boolean = query.contains("mutation ")

servers/graphql-kotlin-server/src/test/kotlin/com/expediagroup/graphql/server/execution/GraphQLServerTest.kt

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.expediagroup.graphql.server.types.GraphQLBatchRequest
2121
import com.expediagroup.graphql.server.types.GraphQLRequest
2222
import io.mockk.coEvery
2323
import io.mockk.coVerify
24+
import io.mockk.every
2425
import io.mockk.mockk
2526
import kotlinx.coroutines.ExperimentalCoroutinesApi
2627
import kotlinx.coroutines.test.runBlockingTest
@@ -36,7 +37,13 @@ class GraphQLServerTest {
3637
@Test
3738
fun `the request handler and parser are called`() {
3839
val mockParser = mockk<GraphQLRequestParser<MockHttpRequest>> {
39-
coEvery { parseRequest(any()) } returns GraphQLBatchRequest(requests = listOf(mockk()))
40+
coEvery { parseRequest(any()) } returns GraphQLBatchRequest(
41+
requests = listOf(
42+
mockk {
43+
every { query } returns "query OperationName { parent { child } }"
44+
}
45+
)
46+
)
4047
}
4148
val mockContextFactory = mockk<GraphQLContextFactory<MockContext, MockHttpRequest>> {
4249
coEvery { generateContext(any()) } returns MockContext()
@@ -57,10 +64,48 @@ class GraphQLServerTest {
5764
}
5865
}
5966

67+
@Test
68+
fun `the request handler and parser are called for a batch with a mutation`() {
69+
val mockParser = mockk<GraphQLRequestParser<MockHttpRequest>> {
70+
coEvery { parseRequest(any()) } returns GraphQLBatchRequest(
71+
requests = listOf(
72+
mockk {
73+
every { query } returns "query OperationName { parent { child } }"
74+
},
75+
mockk {
76+
every { query } returns "mutation OperationName { field { to { mutate } } }"
77+
}
78+
)
79+
)
80+
}
81+
val mockContextFactory = mockk<GraphQLContextFactory<MockContext, MockHttpRequest>> {
82+
coEvery { generateContext(any()) } returns MockContext()
83+
coEvery { generateContextMap(any()) } returns mapOf("foo" to 1)
84+
}
85+
val mockHandler = mockk<GraphQLRequestHandler> {
86+
coEvery { executeRequest(any(), any(), any()) } returns mockk()
87+
}
88+
89+
val server = GraphQLServer(mockParser, mockContextFactory, mockHandler)
90+
91+
runBlockingTest { server.execute(mockk()) }
92+
93+
coVerify(exactly = 1) {
94+
mockParser.parseRequest(any())
95+
mockContextFactory.generateContext(any())
96+
mockContextFactory.generateContextMap(any())
97+
}
98+
coVerify(exactly = 2) {
99+
mockHandler.executeRequest(any(), any(), any())
100+
}
101+
}
102+
60103
@Test
61104
fun `null context is used and passed to the request handler`() {
62105
val mockParser = mockk<GraphQLRequestParser<MockHttpRequest>> {
63-
coEvery { parseRequest(any()) } returns mockk<GraphQLRequest>()
106+
coEvery { parseRequest(any()) } returns mockk<GraphQLRequest> {
107+
every { query } returns "query OperationName { parent { child } }"
108+
}
64109
}
65110
val mockContextFactory = mockk<GraphQLContextFactory<MockContext, MockHttpRequest>> {
66111
coEvery { generateContext(any()) } returns null

servers/graphql-kotlin-server/src/test/kotlin/com/expediagroup/graphql/server/extensions/RequestExtensionsKtTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import org.dataloader.DataLoaderRegistry
2222
import org.junit.jupiter.api.Test
2323
import kotlin.test.assertEquals
2424
import kotlin.test.assertNotNull
25+
import kotlin.test.assertTrue
2526

2627
class RequestExtensionsKtTest {
2728

@@ -75,4 +76,17 @@ class RequestExtensionsKtTest {
7576
val executionInput = request.toExecutionInput(graphQLContextMap = context)
7677
assertEquals(1, executionInput.graphQLContext.get("foo"))
7778
}
79+
80+
@Test
81+
fun `verify graphQLRequest is a mutation`() {
82+
val request = GraphQLRequest(
83+
query = """
84+
mutation addPet(name: "name", petType: "type") {
85+
name
86+
petType
87+
}
88+
""".trimIndent()
89+
)
90+
assertTrue(request.isMutation())
91+
}
7892
}

servers/graphql-kotlin-spring-server/src/main/kotlin/com/expediagroup/graphql/server/spring/GraphQLSchemaConfiguration.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const val DEFAULT_INSTRUMENTATION_ORDER = 0
4747

4848
/**
4949
* Configuration class that loads both the federated and non-federation
50-
* configuraiton and creates the GraphQL schema object and request handler.
50+
* configuration and creates the GraphQL schema object and request handler.
5151
*
5252
* This config can then be used by all Spring specific configuration classes
5353
* to handle incoming requests from HTTP routes or subscriptions and send them

servers/graphql-kotlin-spring-server/src/test/kotlin/com/expediagroup/graphql/server/spring/subscriptions/SubscriptionWebSocketHandlerIT.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class SubscriptionWebSocketHandlerIT(
6565
val dataOutput = TestPublisher.create<String>()
6666

6767
val response = client.execute(uri) { session ->
68-
executeSubsciption(session, startMessage, dataOutput)
68+
executeSubscription(session, startMessage, dataOutput)
6969
}.subscribe()
7070

7171
StepVerifier.create(dataOutput)
@@ -89,7 +89,7 @@ class SubscriptionWebSocketHandlerIT(
8989
val dataOutput = TestPublisher.create<String>()
9090

9191
val response = client.execute(uri) { session ->
92-
executeSubsciption(session, startMessage, dataOutput)
92+
executeSubscription(session, startMessage, dataOutput)
9393
}.subscribe()
9494

9595
StepVerifier.create(dataOutput)
@@ -115,7 +115,7 @@ class SubscriptionWebSocketHandlerIT(
115115
headers.set("X-Custom-Header", "junit")
116116

117117
val response = client.execute(uri, headers) { session ->
118-
executeSubsciption(session, startMessage, dataOutput)
118+
executeSubscription(session, startMessage, dataOutput)
119119
}.subscribe()
120120

121121
StepVerifier.create(dataOutput)
@@ -126,7 +126,7 @@ class SubscriptionWebSocketHandlerIT(
126126
response.dispose()
127127
}
128128

129-
private fun executeSubsciption(
129+
private fun executeSubscription(
130130
session: WebSocketSession,
131131
startMessage: String,
132132
dataOutput: TestPublisher<String>

0 commit comments

Comments
 (0)