Skip to content

Commit

Permalink
Merge pull request #273 from modelix/light-client-for-text-editor
Browse files Browse the repository at this point in the history
Integration of (light-)model-client with modelix.incremental
  • Loading branch information
slisson authored Nov 9, 2023
2 parents 0b4c823 + 1663afa commit a22bb44
Show file tree
Hide file tree
Showing 8 changed files with 751 additions and 47 deletions.
2 changes: 2 additions & 0 deletions light-model-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ kotlin {

api(project(":modelql-untyped"))

implementation(libs.modelix.incremental)
implementation(libs.ktor.client.websockets)
implementation(libs.kotlin.stdlib.common)
implementation(libs.kotlin.logging)
Expand Down Expand Up @@ -59,6 +60,7 @@ kotlin {
// implementation(project(":model-client"))
implementation(project(":model-server"))
implementation(project(":model-server-lib"))
implementation(libs.modelix.incremental)

implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.websocket.WebSockets
import kotlinx.coroutines.delay
import org.modelix.incremental.DependencyTracking
import org.modelix.incremental.IStateVariableGroup
import org.modelix.incremental.IStateVariableReference
import org.modelix.model.api.ConceptReference
import org.modelix.model.api.IBranch
import org.modelix.model.api.IConcept
Expand Down Expand Up @@ -60,9 +63,9 @@ class LightModelClient internal constructor(
val autoFilterNonLoadedNodes: Boolean,
val debugName: String = "",
val modelQLClient: ModelQLClient? = null,
) {
) : IStateVariableGroup {

private val nodes: MutableMap<NodeId, NodeData> = HashMap()
private val nodes = NodesMap<NodeData>(this)
private val area = Area()
private var areaListeners: Set<IAreaListener> = emptySet()
private var repositoryId: String? = null
Expand All @@ -73,8 +76,7 @@ class LightModelClient internal constructor(
private var temporaryIdsSequence: Long = 0
private var changeSetIdSequence: Int = 0
private val nodesReferencingTemporaryIds = HashSet<NodeId>()
private var writeLevel: Int = 0
private val temporaryNodeAdapters: MutableMap<String, NodeAdapter> = HashMap()
private val temporaryNodeAdapters = NodesMap<NodeAdapter>(this)
private var initialized = false
private var lastUnconfirmedChangeSetId: ChangeSetId? = null
private val unappliedVersions: MutableList<VersionData> = ArrayList()
Expand All @@ -91,6 +93,7 @@ class LightModelClient internal constructor(
}
}
transactionManager.afterWrite {
flush()
val changes = object : IAreaChangeList {
override fun visitChanges(visitor: (IAreaChangeEvent) -> Boolean) {}
}
Expand All @@ -104,6 +107,10 @@ class LightModelClient internal constructor(
}
}

override fun getGroup(): IStateVariableGroup? {
return null
}

fun dispose() {
connection.disconnect()
}
Expand Down Expand Up @@ -154,15 +161,7 @@ class LightModelClient internal constructor(
}

fun <T> runWrite(body: () -> T): T {
return transactionManager.runWrite {
writeLevel++
try {
body()
} finally {
writeLevel--
if (writeLevel == 0) flush()
}
}
return transactionManager.runWrite(body)
}

suspend fun waitForRootNode(timeout: Duration = 30.seconds): INode? {
Expand Down Expand Up @@ -202,6 +201,10 @@ class LightModelClient internal constructor(
}
}

fun tryGetParentId(nodeId: NodeId): NodeId? {
return requiresRead { nodes[nodeId]?.parent }
}

fun isInitialized(): Boolean = runRead { initialized }

private fun fullConsistencyCheck() {
Expand All @@ -218,10 +221,6 @@ class LightModelClient internal constructor(
// }
}

fun hasTemporaryIds(): Boolean = requiresRead {
temporaryNodeAdapters.isNotEmpty() || nodesReferencingTemporaryIds.isNotEmpty()
}

fun getNode(nodeId: NodeId): NodeAdapter {
return requiresRead {
getNodeData(nodeId) // fail fast if it doesn't exist
Expand Down Expand Up @@ -718,6 +717,7 @@ internal interface ITransactionManager {
private class ReadWriteLockTransactionManager : ITransactionManager {
private var writeListener: (() -> Unit)? = null
private val lock = ReadWriteLock()
private var writeLevel = 0
override fun <T> requiresRead(body: () -> T): T {
if (!lock.canRead()) throw IllegalStateException("Not in a read transaction")
return body()
Expand All @@ -728,16 +728,18 @@ private class ReadWriteLockTransactionManager : ITransactionManager {
}
override fun <T> runRead(body: () -> T): T = lock.runRead(body)
override fun <T> runWrite(body: () -> T): T {
if (canWrite()) {
return body()
} else {
return lock.runWrite {
writeLevel++
try {
return lock.runWrite(body)
body()
} finally {
try {
writeListener?.invoke()
} catch (ex: Exception) {
mu.KotlinLogging.logger { }.error(ex) { "Exception in write listener" }
writeLevel--
if (writeLevel == 0) {
try {
writeListener?.invoke()
} catch (ex: Exception) {
mu.KotlinLogging.logger { }.error(ex) { "Exception in write listener" }
}
}
}
}
Expand Down Expand Up @@ -901,3 +903,62 @@ fun NodeData.asUpdateData(): NodeUpdateData {
fun INode.isLoaded() = isValid
fun <T : INode> Iterable<T>.filterLoaded() = filter { it.isLoaded() }
fun <T : INode> Sequence<T>.filterLoaded() = filter { it.isLoaded() }

data class NodeDataDependency(val client: LightModelClient, val id: NodeId) : IStateVariableReference<NodeData> {
override fun getGroup(): IStateVariableGroup {
return client.tryGetParentId(id)
?.let { NodeDataDependency(client, it) }
?: client
}

override fun read(): NodeData {
return client.getNode(id).getData()
}
}

private class NodesMap<V : Any>(val client: LightModelClient) {
private val map: MutableMap<NodeId, V> = HashMap()

operator fun get(key: NodeId): V? {
DependencyTracking.accessed(NodeDataDependency(client, key))
return map[key]
}

operator fun set(key: NodeId, value: V) {
if (map[key] == value) return
map[key] = value
DependencyTracking.modified(NodeDataDependency(client, key))
}

fun remove(key: NodeId): V? {
if (!map.containsKey(key)) return null
val result = map.remove(key)
DependencyTracking.modified(NodeDataDependency(client, key))
return result
}

fun clear() {
if (map.isEmpty()) return
val removedKeys = map.keys.toList()
map.clear()
for (key in removedKeys) {
DependencyTracking.modified(NodeDataDependency(client, key))
}
}

fun containsKey(key: NodeId): Boolean {
DependencyTracking.accessed(NodeDataDependency(client, key))
return map.containsKey(key)
}

fun getOrPut(key: NodeId, defaultValue: () -> V): V {
DependencyTracking.accessed(NodeDataDependency(client, key))
map[key]?.let { return it }
val createdValue = defaultValue()
map[key] = createdValue
// No modified notification necessary, because only the first access modifies the map, but then there can't be
// any dependency on that key yet.
// DependencyTracking.modified(NodeDataDependency(client, key))
return createdValue
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.modelix.authorization.installAuthentication
import org.modelix.incremental.IncrementalEngine
import org.modelix.incremental.incrementalFunction
import org.modelix.model.api.IProperty
import org.modelix.model.api.addNewChild
import org.modelix.model.api.getDescendants
import org.modelix.model.server.handlers.DeprecatedLightModelServer
Expand Down Expand Up @@ -116,6 +119,62 @@ class LightModelClientTest {
assertEquals("xyz", client1.runRead { child1.getPropertyValue("name") })
}

@Test
fun incrementalComputationTest() = runClientTest { createClient ->
val client1 = createClient("1")
val client2 = createClient("2")

val rootNode1 = client1.runRead { client1.getRootNode()!! }
val rootNode2 = client2.runRead { client2.getRootNode()!! }
assertEquals(0, client2.runRead { rootNode2.getChildren("role1").toList().size })
val child1 = client1.runWrite { rootNode1.addNewChild("role1") }
assertEquals(1, client1.runRead { rootNode1.getChildren("role1").toList().size })
wait {
client1.checkException()
client2.checkException()
client2.runRead { rootNode2.getChildren("role1").toList().size == 1 }
}
assertEquals(1, client2.runRead { rootNode2.getChildren("role1").toList().size })

client1.runWrite { rootNode1.setPropertyValue(IProperty.fromName("name"), "abc") }
val engine = IncrementalEngine()
try {
var callCount1 = 0
var callCount2 = 0
val nameWithSuffix1 = engine.incrementalFunction("nameWithSuffix1") { _ ->
callCount1++
val name = rootNode1.getPropertyValue(IProperty.fromName("name"))
name + "Suffix1"
}
val nameWithSuffix2 = engine.incrementalFunction("nameWithSuffix2") { _ ->
callCount2++
val name = rootNode2.getPropertyValue(IProperty.fromName("name"))
name + "Suffix2"
}
assertEquals(callCount1, 0)
assertEquals(callCount2, 0)
assertEquals("abcSuffix1", client1.runRead { nameWithSuffix1() })
wait { "abcSuffix2" == client2.runRead { nameWithSuffix2() } }
assertEquals("abcSuffix2", client2.runRead { nameWithSuffix2() })
assertEquals(callCount1, 1)
assertEquals(callCount2, 1)
assertEquals("abcSuffix1", client1.runRead { nameWithSuffix1() })
assertEquals("abcSuffix2", client2.runRead { nameWithSuffix2() })
assertEquals(callCount1, 1)
assertEquals(callCount2, 1)
client1.runWrite { rootNode1.setPropertyValue(IProperty.fromName("name"), "xxx") }
assertEquals(callCount1, 1)
assertEquals(callCount2, 1)
assertEquals("xxxSuffix1", client1.runRead { nameWithSuffix1() })
wait { "xxxSuffix2" == client2.runRead { nameWithSuffix2() } }
assertEquals("xxxSuffix2", client2.runRead { nameWithSuffix2() })
assertEquals(callCount1, 2)
assertEquals(callCount2, 2)
} finally {
engine.dispose()
}
}

@Test
fun random() = runClientTest { createClient ->
val client1 = createClient("1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface ITypedNode {
fun ITypedNode.untyped() = unwrap()
fun ITypedNode.untypedConcept() = _concept.untyped()
fun ITypedNode.typedConcept() = _concept
fun ITypedNode.getPropertyValue(property: IProperty): String? = unwrap().getPropertyValue(property.name)
fun ITypedNode.getPropertyValue(property: IProperty): String? = unwrap().getPropertyValue(property)
fun ITypedNode.instanceOf(concept: ITypedConcept): Boolean {
return instanceOf(concept._concept)
}
Expand Down
1 change: 1 addition & 0 deletions model-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ kotlin {
implementation(project(":modelql-client"))
api(project(":modelql-core"))
implementation(kotlin("stdlib-common"))
implementation(libs.modelix.incremental)
implementation(libs.kotlin.collections.immutable)
implementation(libs.kotlin.coroutines.core)
implementation(libs.kotlin.logging)
Expand Down
Loading

0 comments on commit a22bb44

Please sign in to comment.