Skip to content

Commit c37921b

Browse files
authored
Merge pull request #226 from modelix/node-reference-resolution
fix(model-api): node resolution inside coroutines
2 parents 7fcd8bf + feff7e7 commit c37921b

File tree

40 files changed

+560
-131
lines changed

40 files changed

+560
-131
lines changed

kotlin-utils/build.gradle.kts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
plugins {
2+
`maven-publish`
3+
id("org.jetbrains.kotlin.multiplatform")
4+
}
5+
6+
kotlin {
7+
jvm()
8+
js(IR) {
9+
browser {}
10+
nodejs {
11+
testTask(
12+
Action {
13+
useMocha {
14+
timeout = "30s"
15+
}
16+
},
17+
)
18+
}
19+
useCommonJs()
20+
}
21+
sourceSets {
22+
val commonMain by getting {
23+
dependencies {
24+
implementation(libs.kotlin.coroutines.core)
25+
}
26+
}
27+
val commonTest by getting {
28+
dependencies {
29+
implementation(kotlin("test"))
30+
implementation(libs.kotlin.coroutines.test)
31+
}
32+
}
33+
val jvmMain by getting {
34+
dependencies {
35+
}
36+
}
37+
val jvmTest by getting {
38+
dependencies {
39+
}
40+
}
41+
val jsMain by getting {
42+
dependencies {
43+
}
44+
}
45+
val jsTest by getting {
46+
dependencies {
47+
}
48+
}
49+
}
50+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.modelix.kotlin.utils
15+
16+
/**
17+
* A common abstraction over ThreadLocal and CoroutineContext that integrates both worlds.
18+
* Allows to set a value that can be read from everywhere on the current thread or coroutine.
19+
* A suspendable function can call non suspendable functions and the value is synchronized between the CoroutineContext
20+
* and the internal ThreadLocal.
21+
*/
22+
expect class ContextValue<E> {
23+
24+
constructor()
25+
constructor(defaultValue: E)
26+
27+
/**
28+
* @throws NoSuchElementException if no value is set.
29+
*/
30+
fun getValue(): E
31+
fun getValueOrNull(): E?
32+
fun getAllValues(): List<E>
33+
fun <T> computeWith(newValue: E, body: () -> T): T
34+
35+
suspend fun <T> runInCoroutine(newValue: E, body: suspend () -> T): T
36+
}
37+
38+
fun <E, T> ContextValue<E>.offer(value: E, body: () -> T): T {
39+
return if (getAllValues().isEmpty()) {
40+
computeWith(value, body)
41+
} else {
42+
body()
43+
}
44+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright (c) 2023.
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 org.modelix.kotlin.utils
18+
19+
expect inline fun <R> runSynchronized(lock: Any, block: () -> R): R
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright (c) 2023.
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 org.modelix.kotlin.utils
18+
19+
import kotlinx.coroutines.coroutineScope
20+
import kotlinx.coroutines.delay
21+
import kotlinx.coroutines.launch
22+
import kotlinx.coroutines.test.runTest
23+
import kotlin.test.Test
24+
import kotlin.test.assertEquals
25+
import kotlin.time.Duration.Companion.milliseconds
26+
27+
class ContextValueTests {
28+
29+
/*
30+
* Runs multiple suspendable and non-suspendable functions in parallel to ensure they always read their own value
31+
* and not a value from a different coroutine/thread.
32+
33+
* This test starts two coroutines and tries to run into a race-condition. A successful test doesn't proof the
34+
* correctness, but a failing test proofs its incorrectness. If it ever becomes unstable, meaning it first fails
35+
* and then succeeds after a second run, this shouldn't be ignored.
36+
*/
37+
@Test
38+
fun testIsolation() = runTest {
39+
val contextValue = ContextValue<String>("a")
40+
assertEquals("a", contextValue.getValueOrNull())
41+
coroutineScope {
42+
launch {
43+
for (i in 1..10) {
44+
contextValue.runInCoroutine("b1") {
45+
contextValue.computeWith("b11") {
46+
assertEquals("b11", contextValue.getValueOrNull())
47+
}
48+
assertEquals("b1", contextValue.getValueOrNull())
49+
contextValue.computeWith("b12") {
50+
assertEquals("b12", contextValue.getValueOrNull())
51+
}
52+
delay(1.milliseconds)
53+
}
54+
}
55+
}
56+
launch {
57+
for (i in 1..10) {
58+
contextValue.runInCoroutine("b2") {
59+
assertEquals("b2", contextValue.getValueOrNull())
60+
delay(1.milliseconds)
61+
}
62+
}
63+
}
64+
for (i in 1..5) {
65+
contextValue.runInCoroutine("c") {
66+
assertEquals("c", contextValue.getValueOrNull())
67+
delay(1.milliseconds)
68+
}
69+
}
70+
}
71+
contextValue.computeWith("d") {
72+
assertEquals("d", contextValue.getValueOrNull())
73+
}
74+
assertEquals("a", contextValue.getValueOrNull())
75+
}
76+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.modelix.kotlin.utils
15+
16+
import kotlinx.coroutines.currentCoroutineContext
17+
import kotlinx.coroutines.withContext
18+
import kotlin.coroutines.AbstractCoroutineContextElement
19+
import kotlin.coroutines.Continuation
20+
import kotlin.coroutines.ContinuationInterceptor
21+
import kotlin.coroutines.CoroutineContext
22+
23+
actual class ContextValue<E> {
24+
private val initialStack: List<E>
25+
private var synchronousValueStack: List<E>? = null
26+
private var stackFromCoroutine: List<E>? = null
27+
private val contextElementKey = object : CoroutineContext.Key<ContextValueElement<E>> {}
28+
private var isInSynchronousBlock = false
29+
30+
actual constructor() {
31+
initialStack = emptyList()
32+
}
33+
34+
actual constructor(defaultValue: E) {
35+
initialStack = listOf(defaultValue)
36+
}
37+
38+
actual fun getValue(): E {
39+
return getAllValues().last()
40+
}
41+
42+
actual fun getValueOrNull(): E? {
43+
return getAllValues().lastOrNull()
44+
}
45+
46+
actual fun <T> computeWith(newValue: E, body: () -> T): T {
47+
val oldStack = synchronousValueStack
48+
val newStack = getAllValues() + newValue
49+
val wasInSynchronousBlock = isInSynchronousBlock
50+
try {
51+
isInSynchronousBlock = true
52+
synchronousValueStack = newStack
53+
return body()
54+
} finally {
55+
synchronousValueStack = oldStack
56+
isInSynchronousBlock = wasInSynchronousBlock
57+
}
58+
}
59+
60+
actual fun getAllValues(): List<E> {
61+
return (if (isInSynchronousBlock) synchronousValueStack else stackFromCoroutine) ?: initialStack
62+
}
63+
64+
private suspend fun getAllValuesFromCoroutine(): List<E>? {
65+
return currentCoroutineContext()[contextElementKey]?.stack
66+
}
67+
68+
actual suspend fun <T> runInCoroutine(newValue: E, body: suspend () -> T): T {
69+
return withContext(ContextValueElement((getAllValuesFromCoroutine() ?: initialStack) + newValue)) {
70+
val parentInterceptor = checkNotNull(currentCoroutineContext()[ContinuationInterceptor]) {
71+
"No ContinuationInterceptor found in the context"
72+
}
73+
withContext(Interceptor(parentInterceptor)) {
74+
body()
75+
}
76+
}
77+
}
78+
79+
private inner class Interceptor(
80+
private val dispatcher: ContinuationInterceptor,
81+
) : AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
82+
override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> {
83+
return dispatcher.interceptContinuation(object : Continuation<T> {
84+
override val context get() = continuation.context
85+
86+
override fun resumeWith(result: Result<T>) {
87+
stackFromCoroutine = context[contextElementKey]?.stack ?: emptyList()
88+
continuation.resumeWith(result)
89+
}
90+
})
91+
}
92+
93+
override fun releaseInterceptedContinuation(continuation: Continuation<*>) {
94+
super.releaseInterceptedContinuation(continuation)
95+
stackFromCoroutine = null
96+
}
97+
}
98+
99+
inner class ContextValueElement<E>(val stack: List<E>) : AbstractCoroutineContextElement(contextElementKey)
100+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright (c) 2023.
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 org.modelix.kotlin.utils
18+
19+
actual inline fun <R> runSynchronized(lock: Any, block: () -> R): R {
20+
// This method is only required when compiling to the JVM. JS is single-threaded.
21+
return block()
22+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.modelix.kotlin.utils
15+
16+
import kotlinx.coroutines.asContextElement
17+
import kotlinx.coroutines.withContext
18+
19+
actual class ContextValue<E>(private val initialStack: List<E>) {
20+
21+
private val valueStack = ThreadLocal.withInitial { initialStack }
22+
23+
actual constructor() : this(emptyList())
24+
25+
actual constructor(defaultValue: E) : this(listOf(defaultValue))
26+
27+
actual fun <T> computeWith(newValue: E, body: () -> T): T {
28+
val oldStack: List<E> = valueStack.get()
29+
return try {
30+
valueStack.set(oldStack + newValue)
31+
body()
32+
} finally {
33+
valueStack.set(oldStack)
34+
}
35+
}
36+
37+
actual suspend fun <T> runInCoroutine(newValue: E, body: suspend () -> T): T {
38+
return withContext(valueStack.asContextElement(getAllValues() + newValue)) {
39+
body()
40+
}
41+
}
42+
43+
actual fun getValue(): E {
44+
return valueStack.get().last()
45+
}
46+
47+
actual fun getValueOrNull(): E? {
48+
return valueStack.get().lastOrNull()
49+
}
50+
51+
actual fun getAllValues(): List<E> {
52+
return valueStack.get()
53+
}
54+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright (c) 2023.
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 org.modelix.kotlin.utils
18+
19+
actual inline fun <R> runSynchronized(lock: Any, block: () -> R): R {
20+
return synchronized(lock, block)
21+
}

0 commit comments

Comments
 (0)