Skip to content

Commit 0314c3d

Browse files
authored
Merge pull request #171 from modelix/feature/model-sync-lib-performance
proposal for a more efficient model sync algorithm
2 parents fe7f8c8 + 38f6518 commit 0314c3d

File tree

3 files changed

+87
-180
lines changed

3 files changed

+87
-180
lines changed

model-client/src/commonMain/kotlin/org/modelix/model/operations/MoveNodeOp.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ class MoveNodeOp(val childId: Long, val targetPosition: PositionInRole) : Abstra
4848
override fun invert(): List<IOperation> {
4949
return listOf(MoveNodeOp(childId, sourcePosition))
5050
}
51+
52+
override fun toString(): String {
53+
return "applied:MoveNodeOp ${childId.toString(16)}: $sourcePosition->$targetPosition"
54+
}
5155
}
5256

5357
override fun captureIntend(tree: ITree, store: IDeserializingKeyValueStore): IOperationIntend {

model-sync-lib/src/commonMain/kotlin/org/modelix/model/sync/ModelImporter.kt

Lines changed: 69 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import java.io.File
88
class ModelImporter(private val root: INode) {
99

1010
private val originalIdToExisting: MutableMap<String, INode> = mutableMapOf()
11-
private val originalIdToSpec: MutableMap<String, NodeData> = mutableMapOf()
11+
private val postponedReferences = ArrayList<() -> Unit>()
12+
private val nodesToRemove = HashSet<INode>()
1213

1314
fun import(jsonFile: File) {
1415
require(jsonFile.exists())
@@ -20,27 +21,60 @@ class ModelImporter(private val root: INode) {
2021

2122
fun import(data: ModelData) {
2223
originalIdToExisting.clear()
23-
originalIdToSpec.clear()
24-
25-
syncProperties(root, data.root) // root original id is required for following operations
26-
buildSpecIndex(data.root)
27-
28-
syncAllProperties(root.getDescendants(false))
24+
postponedReferences.clear()
25+
nodesToRemove.clear()
2926
buildExistingIndex(root)
27+
data.root.originalId()?.let { originalIdToExisting[it] = root }
28+
syncNode(root, data.root)
29+
postponedReferences.forEach { it.invoke() }
30+
nodesToRemove.forEach { it.remove() }
31+
}
3032

31-
sortAllExistingChildren(root)
32-
val addedNodes = addAllMissingChildren(root)
33-
syncAllProperties(addedNodes.asSequence())
33+
private fun syncNode(node: INode, data: NodeData) {
34+
syncProperties(node, data)
35+
syncChildren(node, data)
36+
syncReferences(node, data)
37+
}
3438

35-
handleAllMovesAcrossParents(root)
39+
private fun syncChildren(node: INode, data: NodeData) {
40+
val allRoles = (data.children.map { it.role } + node.allChildren.map { it.roleInParent }).distinct()
41+
for (role in allRoles) {
42+
val expectedNodes = data.children.filter { it.role == role }
43+
val existingNodes = node.getChildren(role).toList()
3644

37-
addedNodes.forEach {node ->
38-
node.originalId()?.let { originalIdToExisting[it] = node }
39-
}
45+
// optimization for when there is no change in the child list
46+
// size check first to avoid querying the original ID
47+
if (expectedNodes.size == existingNodes.size && expectedNodes.map { it.originalId() } == existingNodes.map { it.originalId() }) {
48+
existingNodes.zip(expectedNodes).forEach { syncNode(it.first, it.second) }
49+
continue
50+
}
4051

41-
syncAllReferences(root)
52+
expectedNodes.forEachIndexed { index, expected ->
53+
val nodeAtIndex = node.getChildren(role).toList().getOrNull(index)
54+
val expectedId = expected.originalId() ?: TODO()
55+
val expectedConcept = expected.concept?.let { s -> ConceptReference(s) }
56+
val childNode = if (nodeAtIndex?.originalId() != expectedId) {
57+
val existingNode = originalIdToExisting[expectedId]
58+
if (existingNode == null) {
59+
val newChild = node.addNewChild(role, index, expectedConcept)
60+
newChild.setPropertyValue(NodeData.idPropertyKey, expectedId)
61+
originalIdToExisting[expectedId] = newChild
62+
newChild
63+
} else {
64+
node.moveChild(role, index, existingNode)
65+
nodesToRemove.remove(existingNode)
66+
existingNode
67+
}
68+
} else {
69+
nodeAtIndex
70+
}
71+
if (childNode.getConceptReference() != expectedConcept) TODO()
4272

43-
deleteAllExtraChildren(root)
73+
syncNode(childNode, expected)
74+
}
75+
76+
nodesToRemove += node.getChildren(role).drop(expectedNodes.size)
77+
}
4478
}
4579

4680
private fun buildExistingIndex(root: INode) {
@@ -49,17 +83,6 @@ class ModelImporter(private val root: INode) {
4983
}
5084
}
5185

52-
private fun buildSpecIndex(nodeData: NodeData) {
53-
nodeData.originalId()?.let { originalIdToSpec[it] = nodeData }
54-
nodeData.children.forEach { buildSpecIndex(it) }
55-
}
56-
57-
private fun syncAllProperties(nodeSequence: Sequence<INode>) {
58-
nodeSequence.forEach {node ->
59-
originalIdToSpec[node.originalId()]?.let { spec -> syncProperties(node, spec) }
60-
}
61-
}
62-
6386
private fun syncProperties(node: INode, nodeData: NodeData) {
6487
if (node.getPropertyValue(NodeData.idPropertyKey) == null) {
6588
node.setPropertyValue(NodeData.idPropertyKey, nodeData.originalId())
@@ -77,164 +100,34 @@ class ModelImporter(private val root: INode) {
77100
toBeRemoved.forEach { node.setPropertyValue(it, null) }
78101
}
79102

80-
private fun syncAllReferences(root: INode) {
81-
root.getDescendants(true).forEach {node ->
82-
originalIdToSpec[node.originalId()]?.let { spec -> syncReferences(node, spec) }
83-
}
84-
}
85-
86103
private fun syncReferences(node: INode, nodeData: NodeData) {
87104
nodeData.references.forEach {
88-
if (node.getReferenceTargetRef(it.key) != originalIdToExisting[it.value]?.reference) {
89-
node.setReferenceTarget(it.key, originalIdToExisting[it.value]?.reference)
105+
val expectedTargetId = it.value
106+
val actualTargetId = node.getReferenceTarget(it.key)?.originalId()
107+
if (actualTargetId != expectedTargetId) {
108+
val expectedTarget = originalIdToExisting[expectedTargetId]
109+
if (expectedTarget == null) {
110+
postponedReferences += {
111+
val expectedTarget = originalIdToExisting[expectedTargetId]
112+
if (expectedTarget == null) {
113+
// The target node is not part of the model. Assuming it exists in some other model we can
114+
// store the reference and try to resolve it dynamically on access.
115+
node.setReferenceTarget(it.key, SerializedNodeReference(expectedTargetId))
116+
} else {
117+
node.setReferenceTarget(it.key, expectedTarget)
118+
}
119+
}
120+
} else {
121+
node.setReferenceTarget(it.key, expectedTarget)
122+
}
90123
}
91124
}
92-
val toBeRemoved = node.getReferenceRoles().toSet().subtract(nodeData.references.keys)
125+
val toBeRemoved = node.getReferenceRoles().toSet() - nodeData.references.keys
93126
toBeRemoved.forEach {
94127
val nullReference: INodeReference? = null
95128
node.setReferenceTarget(it, nullReference)
96129
}
97130
}
98-
99-
private fun addAllMissingChildren(node: INode): List<INode> {
100-
val addedNodes = mutableListOf<INode>()
101-
originalIdToSpec[node.originalId()]?.let {
102-
addedNodes.addAll(
103-
addMissingChildren(node, it)
104-
)
105-
}
106-
node.allChildren.forEach {
107-
addedNodes.addAll(addAllMissingChildren(it))
108-
}
109-
return addedNodes
110-
}
111-
112-
private fun addMissingChildren(node: INode, nodeData: NodeData): List<INode> {
113-
val specifiedChildren = nodeData.children.toList()
114-
val toBeAdded = specifiedChildren.filter { !originalIdToExisting.contains(it.originalId()) }
115-
116-
return toBeAdded.map { nodeToBeAdded ->
117-
val childrenInRole = node.allChildren.filter { it.roleInParent == nodeToBeAdded.role }
118-
val existingIds = childrenInRole.map { it.originalId() }
119-
val baseIndex = nodeToBeAdded.getIndexWithinRole(nodeData)
120-
var offset = 0
121-
offset += childrenInRole.slice(0..minOf(baseIndex, childrenInRole.lastIndex)).count {
122-
!originalIdToSpec.containsKey(it.originalId()) // node will be deleted
123-
}
124-
offset -= specifiedChildren.filter { it.role == nodeToBeAdded.role }.slice(0 until baseIndex).count {
125-
!existingIds.contains(it.originalId()) // node will be moved here
126-
}
127-
val index = (baseIndex + offset).coerceIn(0..childrenInRole.size)
128-
val concept = nodeToBeAdded.concept?.let { s -> ConceptReference(s) }
129-
130-
node.addNewChild(nodeToBeAdded.role, index, concept).apply {
131-
setPropertyValue(NodeData.idPropertyKey, nodeToBeAdded.originalId())
132-
}
133-
}
134-
}
135-
136-
private fun sortAllExistingChildren(root: INode) {
137-
root.getDescendants(true).forEach { node ->
138-
originalIdToSpec[node.originalId()]?.let { sortExistingChildren(node, it) }
139-
}
140-
}
141-
142-
private fun sortExistingChildren(node: INode, nodeData: NodeData) {
143-
val existingChildren = node.allChildren.toList()
144-
val existingIds = existingChildren.map { it.originalId() }
145-
val specifiedChildren = nodeData.children
146-
val toBeSortedSpec = specifiedChildren.filter { originalIdToExisting.containsKey(it.originalId()) }
147-
148-
val targetIndices = HashMap<String?, Int>(nodeData.children.size)
149-
for (child in toBeSortedSpec) {
150-
val childrenInRole = existingChildren.filter { it.roleInParent == child.role }
151-
val baseIndex = child.getIndexWithinRole(nodeData)
152-
var offset = 0
153-
offset += childrenInRole.slice(0..baseIndex.coerceAtMost(childrenInRole.lastIndex)).count {
154-
!originalIdToSpec.containsKey(it.originalId()) // node will be deleted
155-
}
156-
offset -= specifiedChildren
157-
.filter { it.role == child.role }
158-
.slice(0..baseIndex.coerceIn(0..specifiedChildren.lastIndex))
159-
.count {
160-
!existingIds.contains(it.originalId()) // node will be moved here
161-
}
162-
val index = (baseIndex + offset).coerceIn(0..childrenInRole.size)
163-
targetIndices[child.originalId()] = index
164-
}
165-
166-
existingChildren.forEach { child ->
167-
val currentIndex = child.index()
168-
val targetRole = originalIdToSpec[child.originalId()]?.role
169-
val targetIndex = targetIndices[child.originalId()]
170-
if (targetIndex != null && (targetIndex != currentIndex || child.roleInParent != targetRole)) {
171-
node.moveChild(targetRole, targetIndex, child)
172-
}
173-
}
174-
}
175-
176-
private fun handleAllMovesAcrossParents(root: INode) {
177-
val moves = collectMovesAcrossParents(root)
178-
while (moves.isNotEmpty()) {
179-
val nextMove = moves.first { !it.nodeToBeMoved.getDescendants(false).contains(it.targetParent) }
180-
performMoveAcrossParents(nextMove.targetParent, nextMove.nodeToBeMoved)
181-
moves.remove(nextMove)
182-
}
183-
}
184-
185-
private fun collectMovesAcrossParents(root: INode): MutableList<MoveAcrossParents> {
186-
val movesAcrossParents = mutableListOf<MoveAcrossParents>()
187-
root.getDescendants(true).forEach {node ->
188-
val nodeData = originalIdToSpec[node.originalId()] ?: return@forEach
189-
190-
val missingIds = nodeData.children.map { it.originalId() }.toSet()
191-
.subtract(node.allChildren.map { it.originalId() }.toSet())
192-
val toBeMovedHere = missingIds
193-
.filter { originalIdToSpec.containsKey(it) }
194-
.mapNotNull { originalIdToExisting[it] }
195-
196-
toBeMovedHere.forEach {
197-
movesAcrossParents.add(MoveAcrossParents(node, it))
198-
}
199-
}
200-
return movesAcrossParents
201-
}
202-
203-
private data class MoveAcrossParents(val targetParent: INode, val nodeToBeMoved: INode)
204-
205-
private fun performMoveAcrossParents(node: INode, toBeMovedHere: INode) {
206-
val nodeData = originalIdToSpec[node.originalId()] ?: return
207-
val existingChildren = node.allChildren.toList()
208-
val spec = originalIdToSpec[toBeMovedHere.originalId()]!!
209-
val childrenInRole = existingChildren.filter { it.roleInParent == spec.role }
210-
val baseTargetIndex = spec.getIndexWithinRole(nodeData).coerceAtMost(childrenInRole.size)
211-
val offset = childrenInRole.slice(0 until baseTargetIndex).count {
212-
!originalIdToSpec.containsKey(it.originalId()) // node will be deleted
213-
}
214-
val targetIndex = (baseTargetIndex + offset).coerceIn(0..childrenInRole.size)
215-
216-
node.moveChild(spec.role, targetIndex, toBeMovedHere)
217-
218-
}
219-
220-
private fun deleteAllExtraChildren(root: INode) {
221-
val toBeRemoved = mutableListOf<INode>()
222-
root.allChildren.forEach {
223-
if (!originalIdToSpec.containsKey(it.originalId())) {
224-
toBeRemoved.add(it)
225-
}
226-
}
227-
toBeRemoved.forEach {
228-
it.parent?.removeChild(it)
229-
}
230-
root.allChildren.forEach {
231-
deleteAllExtraChildren(it)
232-
}
233-
}
234-
235-
private fun NodeData.getIndexWithinRole(parent: NodeData) : Int =
236-
parent.children.filter { it.role == this.role }.indexOf(this)
237-
238131
}
239132

240133
internal fun INode.originalId(): String? {

model-sync-lib/src/commonTest/kotlin/org/modelix/model/sync/ModelImporterTest.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,18 @@ class ModelImporterTest {
9797
@Test
9898
fun `uses minimal amount of operations`() {
9999
val operations = branch.operationsAndTree.first
100+
println(operations.joinToString("\n"))
100101

101102
val numOps = operations.numOpsByType()
102103
val numPropertyChangesIgnoringOriginalId =
103104
numOps[AddNewChildOp::class]?.let { numOps[SetPropertyOp::class]?.minus(it) } ?: 0
104105

105106
assertEquals(1, numOps[AddNewChildOp::class])
106107
assertEquals(1, numOps[DeleteNodeOp::class])
107-
assertEquals(5, numOps[MoveNodeOp::class])
108+
109+
// could be done in 5, but finding that optimization makes the sync algorithm slower
110+
assertEquals(6, numOps[MoveNodeOp::class])
111+
108112
assertEquals(4, numPropertyChangesIgnoringOriginalId)
109113
assertEquals(3, numOps[SetReferenceOp::class])
110114
}
@@ -128,25 +132,31 @@ class ModelImporterTest {
128132

129133
@Test
130134
fun `can handle random changes`() {
135+
for (seed in (1..100)) {
136+
runRandomTest(seed + 6787456)
137+
}
138+
}
139+
140+
private fun runRandomTest(seed: Int) {
131141
val tree0 = CLTree(ObjectStoreCache(MapBaseStore()))
132142
val branch0 = PBranch(tree0, IdGenerator.getInstance(1))
133143

134-
val seed = Random.nextInt()
135144
println("Seed for random change test: $seed")
145+
val rand = Random(seed)
136146
lateinit var initialState: NodeData
137147
lateinit var specification: NodeData
138148
val numChanges = 50
139149

140150
branch0.runWrite {
141151
val rootNode = branch0.getRootNode()
142152
rootNode.setPropertyValue(NodeData.idPropertyKey, rootNode.reference.serialize())
143-
val grower = RandomModelChangeGenerator(rootNode, Random(seed)).growingOperationsOnly()
153+
val grower = RandomModelChangeGenerator(rootNode, rand).growingOperationsOnly()
144154
for (i in 1..100) {
145155
grower.applyRandomChange()
146156
}
147157
initialState = rootNode.asExported()
148158

149-
val changer = RandomModelChangeGenerator(rootNode, Random(seed))
159+
val changer = RandomModelChangeGenerator(rootNode, rand)
150160
for (i in 1..numChanges) {
151161
changer.applyRandomChange()
152162
}

0 commit comments

Comments
 (0)