Skip to content

Commit 4bf8460

Browse files
committed
feat(modelql): new steps: memoize, find, findAll, sum, toFlux
The removal of the InMemoryModel reduces the memory consumption, but increases the overhead of navigations on the model. This commit introduces a caching mechanism for avoiding repeated traversals of the same model parts. On average, this should improve the performance enough to compensate the increased overhead.
1 parent dbf7eb9 commit 4bf8460

File tree

86 files changed

+1203
-176
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+1203
-176
lines changed

docs/global/modules/core/pages/howto/modelql.adoc

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,38 @@ val html = query {
195195

196196
`buildHtmlQuery` and the `requestFragment` operation are similar to the `buildLocalMapping` operation,
197197
but inside the `onSuccess` block you use the Kotlin HTML DSL.
198+
199+
== Indices/Caching
200+
201+
To search for a node efficiently, `.find` can be used. Internally, it creates a map of all the elements and reuses that
202+
in following queries.
203+
204+
[source,kotlin]
205+
--
206+
val nodeId: String
207+
root.find(
208+
// Provides all nodes to index
209+
{ it.descendants() },
210+
// selects the index key for each node
211+
{ it.nodeReferenceAsString() },
212+
// The key to search for in the current request
213+
nodeId.asMono()
214+
)
215+
--
216+
217+
It's also possible to search for multiple nodes:
218+
219+
[source,kotlin]
220+
--
221+
val name: String
222+
root.findAll({ it.descendants().ofConcept(C_INamedConcept) }, { it.name }, name.asMono())
223+
--
224+
225+
Internally, they both use the `memoize` operation. `memoize` stores the result of the query and reuses it without
226+
re-executing the query.
227+
228+
The `find` example is equivalent to this:
229+
[source,kotlin]
230+
--
231+
root.memoize { it.descendants().associateBy { it.nodeReferenceAsString() } }.get(nodeId.asMono()).filterNotNull()
232+
--

model-api-gen-runtime/src/commonMain/kotlin/org/modelix/metamodel/GeneratedConcept.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ class GeneratedProperty<ValueT>(
208208
override fun getSimpleName(): String = simpleName
209209

210210
override fun toReference(): IPropertyReference = IPropertyReference.fromIdAndName(uid, simpleName)
211+
212+
override fun toString(): String = "${getUID()}(${getConcept().getShortName()}.$simpleName)"
211213
}
212214
fun IProperty.typed() = this as? ITypedProperty<*>
213215

@@ -240,6 +242,8 @@ abstract class GeneratedChildLink<ChildNodeT : ITypedNode, ChildConceptT : IConc
240242
override fun getSimpleName(): String = simpleName
241243

242244
override fun toReference(): IChildLinkReference = IChildLinkReference.fromIdAndName(uid, simpleName)
245+
246+
override fun toString(): String = "${getUID()}(${getConcept().getShortName()}.$simpleName)"
243247
}
244248
fun IChildLink.typed(): ITypedChildLink<ITypedNode> {
245249
return this as? ITypedChildLink<ITypedNode>
@@ -295,5 +299,7 @@ class GeneratedReferenceLink<TargetNodeT : ITypedNode, TargetConceptT : IConcept
295299
override fun getSimpleName(): String = simpleName
296300

297301
override fun toReference(): IReferenceLinkReference = IReferenceLinkReference.fromIdAndName(uid, simpleName)
302+
303+
override fun toString(): String = "${getUID()}(${getConcept().getShortName()}.$simpleName)"
298304
}
299305
fun IReferenceLink.typed() = this as? ITypedReferenceLink<ITypedNode> ?: UnknownTypedReferenceLink(this)

model-api/src/commonMain/kotlin/org/modelix/model/api/IdGeneratorDummy.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,14 @@ class IdGeneratorDummy : IIdGenerator {
44
override fun generate(): Long {
55
throw UnsupportedOperationException("Unexpected generation of IDs")
66
}
7+
8+
override fun equals(other: Any?): Boolean {
9+
if (this === other) return true
10+
if (other == null || this::class != other::class) return false
11+
return true
12+
}
13+
14+
override fun hashCode(): Int {
15+
return this::class.hashCode()
16+
}
717
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright (c) 2024.
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.model.api
18+
19+
data class TreeAsBranch(override val tree: ITree) : IBranch, IReadTransaction {
20+
override fun getUserObject(key: Any) = null
21+
22+
override fun putUserObject(key: Any, value: Any?) {
23+
throw UnsupportedOperationException("read-only")
24+
}
25+
26+
override fun addListener(l: IBranchListener) {
27+
// branch will never change
28+
}
29+
30+
override fun getId(): String {
31+
return tree.getId() ?: throw UnsupportedOperationException()
32+
}
33+
34+
override fun runRead(runnable: () -> Unit) {
35+
computeRead(runnable)
36+
}
37+
38+
override fun <T> computeRead(computable: () -> T): T {
39+
return RoleAccessContext.runWith(tree.usesRoleIds()) { computable() }
40+
}
41+
42+
override fun runWrite(runnable: () -> Unit) {
43+
throw UnsupportedOperationException("read-only")
44+
}
45+
46+
override fun <T> computeWrite(computable: () -> T): T {
47+
throw UnsupportedOperationException("read-only")
48+
}
49+
50+
override fun canRead(): Boolean {
51+
return true
52+
}
53+
54+
override fun canWrite(): Boolean {
55+
return false
56+
}
57+
58+
override val transaction: ITransaction
59+
get() = this
60+
override val readTransaction: IReadTransaction
61+
get() = this
62+
override val writeTransaction: IWriteTransaction
63+
get() = throw UnsupportedOperationException("read-only")
64+
65+
override fun removeListener(l: IBranchListener) {
66+
// branch will never change
67+
}
68+
69+
override val branch: IBranch
70+
get() = this
71+
72+
override fun containsNode(nodeId: Long) = tree.containsNode(nodeId)
73+
74+
override fun getConcept(nodeId: Long) = tree.getConcept(nodeId)
75+
76+
override fun getConceptReference(nodeId: Long): IConceptReference? = tree.getConceptReference(nodeId)
77+
78+
override fun getParent(nodeId: Long) = tree.getParent(nodeId)
79+
80+
override fun getRole(nodeId: Long) = tree.getRole(nodeId)
81+
82+
override fun getProperty(nodeId: Long, role: String) = tree.getProperty(nodeId, role)
83+
84+
override fun getReferenceTarget(sourceId: Long, role: String) = tree.getReferenceTarget(sourceId, role)
85+
86+
override fun getChildren(parentId: Long, role: String?) = tree.getChildren(parentId, role)
87+
88+
override fun getAllChildren(parentId: Long) = tree.getAllChildren(parentId)
89+
90+
override fun getReferenceRoles(sourceId: Long) = tree.getReferenceRoles(sourceId)
91+
92+
override fun getPropertyRoles(sourceId: Long) = tree.getPropertyRoles(sourceId)
93+
}

model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/CLTree.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@ class CLTree(val data: CPTree, val asyncStore: IAsyncObjectStore) : ITree by Asy
133133
return "CLTree[${data.hash}]"
134134
}
135135

136+
override fun equals(other: Any?): Boolean {
137+
if (this === other) return true
138+
if (other == null || this::class != other::class) return false
139+
140+
other as CLTree
141+
142+
return hash == other.hash
143+
}
144+
145+
override fun hashCode(): Int {
146+
return hash.hashCode()
147+
}
148+
136149
companion object {
137150
fun builder(store: IDeserializingKeyValueStore) = Builder(store.getAsyncStore())
138151
fun builder(store: IAsyncObjectStore) = Builder(store)

model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/ObjectStoreCache.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,14 @@ class ObjectStoreCache @JvmOverloads constructor(
174174
class LRUCache<K : Any, V>(val maxSize: Int) {
175175
private val map: MutableMap<K, V> = LinkedHashMap()
176176

177+
@Synchronized
177178
operator fun set(key: K, value: V) {
178179
map.remove(key)
179180
map[key] = value
180181
while (map.size > maxSize) map.remove(map.iterator().next().key)
181182
}
182183

184+
@Synchronized
183185
operator fun get(key: K, updatePosition: Boolean = true): V? {
184186
return map[key]?.also { value ->
185187
if (updatePosition) {
@@ -189,10 +191,12 @@ class LRUCache<K : Any, V>(val maxSize: Int) {
189191
}
190192
}
191193

194+
@Synchronized
192195
fun remove(key: K) {
193196
map.remove(key)
194197
}
195198

199+
@Synchronized
196200
fun clear() {
197201
map.clear()
198202
}

model-server/src/main/kotlin/org/modelix/model/server/handlers/ModelReplicationServer.kt

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package org.modelix.model.server.handlers
1616

17+
import com.google.common.cache.CacheBuilder
1718
import io.ktor.http.ContentType
1819
import io.ktor.http.HttpStatusCode
1920
import io.ktor.server.application.Application
@@ -43,9 +44,12 @@ import org.modelix.authorization.checkPermission
4344
import org.modelix.authorization.getUserName
4445
import org.modelix.authorization.hasPermission
4546
import org.modelix.authorization.requiresLogin
47+
import org.modelix.model.api.IBranch
4648
import org.modelix.model.api.PBranch
49+
import org.modelix.model.api.TreeAsBranch
4750
import org.modelix.model.api.TreePointer
4851
import org.modelix.model.api.getRootNode
52+
import org.modelix.model.api.runSynchronized
4953
import org.modelix.model.area.getArea
5054
import org.modelix.model.client2.checkObjectHashes
5155
import org.modelix.model.client2.getAllObjects
@@ -59,7 +63,15 @@ import org.modelix.model.server.api.v2.VersionDelta
5963
import org.modelix.model.server.api.v2.VersionDeltaStream
6064
import org.modelix.model.server.api.v2.VersionDeltaStreamV2
6165
import org.modelix.model.server.store.StoreManager
66+
import org.modelix.modelql.core.IMemoizationPersistence
67+
import org.modelix.modelql.core.IStepOutput
68+
import org.modelix.modelql.core.MonoUnboundQuery
69+
import org.modelix.modelql.core.QueryEvaluationContext
70+
import org.modelix.modelql.core.QueryGraphDescriptor
71+
import org.modelix.modelql.core.upcast
6272
import org.modelix.modelql.server.ModelQLServer
73+
import org.modelix.streams.exactlyOne
74+
import org.modelix.streams.getSynchronous
6375
import org.slf4j.LoggerFactory
6476

6577
/**
@@ -76,6 +88,7 @@ class ModelReplicationServer(
7688
}
7789

7890
private val stores: StoreManager get() = repositoriesManager.getStoreManager()
91+
private val indexPersistence: IMemoizationPersistence = InMemoryMemoizationPersistence()
7992

8093
fun init(application: Application) {
8194
application.routing {
@@ -257,36 +270,45 @@ class ModelReplicationServer(
257270

258271
override suspend fun PipelineContext<Unit, ApplicationCall>.postRepositoryBranchQuery(
259272
repository: String,
260-
branch: String,
273+
branchName: String,
261274
) {
262-
val branchRef = repositoryId(repository).getBranchReference(branch)
275+
val branchRef = repositoryId(repository).getBranchReference(branchName)
263276
checkPermission(ModelServerPermissionSchema.branch(branchRef).query)
264277
val version = repositoriesManager.getVersion(branchRef) ?: throw BranchNotFoundException(branchRef)
265278
LOG.trace("Running query on {} @ {}", branchRef, version)
266279
val initialTree = version.getTree()
267-
val otBranch = OTBranch(
268-
PBranch(initialTree, stores.idGenerator),
269-
stores.idGenerator,
270-
repositoriesManager.getLegacyObjectStore(RepositoryId(repository)),
271-
)
272280

273-
ModelQLServer.handleCall(call, { writeAccess ->
274-
otBranch.getRootNode() to otBranch.getArea()
275-
}, {
276-
// writing the new version has to happen before call.respond is invoked, otherwise subsequent queries
277-
// from the same client may still be executed on the old version.
278-
val (ops, newTree) = otBranch.getPendingChanges()
279-
if (newTree != initialTree) {
280-
val newVersion = CLVersion.createRegularVersion(
281-
id = stores.idGenerator.generate(),
282-
author = getUserName(),
283-
tree = newTree,
284-
baseVersion = version,
285-
operations = ops.map { it.getOriginalOp() }.toTypedArray(),
286-
)
287-
repositoriesManager.mergeChanges(branchRef, newVersion.getContentHash())
288-
}
289-
})
281+
IMemoizationPersistence.CONTEXT_INSTANCE.runInCoroutine(indexPersistence) {
282+
lateinit var branch: IBranch
283+
ModelQLServer.handleCall(call, { writeAccess ->
284+
branch = if (writeAccess) {
285+
OTBranch(
286+
PBranch(initialTree, stores.idGenerator),
287+
stores.idGenerator,
288+
repositoriesManager.getLegacyObjectStore(RepositoryId(repository)),
289+
)
290+
} else {
291+
TreeAsBranch(initialTree)
292+
}
293+
branch.getRootNode() to branch.getArea()
294+
}, {
295+
// writing the new version has to happen before call.respond is invoked, otherwise subsequent queries
296+
// from the same client may still be executed on the old version.
297+
(branch as? OTBranch)?.let { otBranch ->
298+
val (ops, newTree) = otBranch.getPendingChanges()
299+
if (newTree != initialTree) {
300+
val newVersion = CLVersion.createRegularVersion(
301+
id = stores.idGenerator.generate(),
302+
author = getUserName(),
303+
tree = newTree,
304+
baseVersion = version,
305+
operations = ops.map { it.getOriginalOp() }.toTypedArray(),
306+
)
307+
repositoriesManager.mergeChanges(branchRef, newVersion.getContentHash())
308+
}
309+
}
310+
})
311+
}
290312
}
291313

292314
override suspend fun PipelineContext<Unit, ApplicationCall>.postRepositoryVersionHashQuery(
@@ -445,3 +467,35 @@ private fun PipelineContext<Unit, ApplicationCall>.parameter(name: String): Stri
445467
private fun ApplicationCall.parameter(name: String): String {
446468
return requireNotNull(parameters[name]) { "Unknown parameter '$name'" }
447469
}
470+
471+
class InMemoryMemoizationPersistence : IMemoizationPersistence {
472+
473+
private val cache = CacheBuilder.newBuilder().softValues().build<IndexCacheKey, IStepOutput<*>>()
474+
475+
/**
476+
* Used for deduplication of instances to safe memory.
477+
*/
478+
private val descriptorInstances = CacheBuilder.newBuilder().maximumSize(100).build<QueryGraphDescriptor, QueryGraphDescriptor>()
479+
480+
override fun <In, Out> getMemoizer(query: MonoUnboundQuery<In, Out>): IMemoizationPersistence.Memoizer<In, Out> {
481+
return MemoizerImpl(query, query.createDescriptor().normalize().deduplicate())
482+
}
483+
484+
private inner class MemoizerImpl<In, Out>(val query: MonoUnboundQuery<In, Out>, val normalizedQueryDescriptor: QueryGraphDescriptor) : IMemoizationPersistence.Memoizer<In, Out> {
485+
override fun memoize(input: IStepOutput<In>): IStepOutput<Out> {
486+
runSynchronized(cache) {
487+
return cache.get(IndexCacheKey(normalizedQueryDescriptor, input)) {
488+
query.asFlow(QueryEvaluationContext.EMPTY, input).exactlyOne().getSynchronous()
489+
}.upcast()
490+
}
491+
}
492+
}
493+
494+
private fun QueryGraphDescriptor.deduplicate() = descriptorInstances.get(this) { this }
495+
496+
private data class IndexCacheKey(val query: QueryGraphDescriptor, val input: Any?)
497+
498+
private class IndexData<K, V>(val map: Map<K, List<IStepOutput<V>>>)
499+
500+
private fun <K, V> IndexData<*, *>.upcast() = this as IndexData<K, V>
501+
}

model-server/src/main/kotlin/org/modelix/model/server/store/StoreManager.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ class StoreManager(val genericStore: IsolatingStore) {
4949
val existing = repositorySpecificStores[repository]?.get()
5050
if (existing != null) return existing
5151

52-
val newStore = BulkAsyncStore(CachingAsyncStore(StoreClientAsAsyncStore(getStoreClient(repository))))
52+
val newStore = BulkAsyncStore(
53+
CachingAsyncStore(
54+
StoreClientAsAsyncStore(getStoreClient(repository)),
55+
cacheSize = System.getenv("MODELIX_OBJECT_CACHE_SIZE")?.toIntOrNull() ?: 500_000,
56+
),
57+
)
5358
repositorySpecificStores[repository] = SoftReference(newStore)
5459
return newStore
5560
}

0 commit comments

Comments
 (0)