Skip to content

Commit 72edd28

Browse files
committed
fix(bulk-model-sync): after deleting a node many unnecessary move operations were produced
1 parent 6a02a80 commit 72edd28

File tree

3 files changed

+150
-48
lines changed

3 files changed

+150
-48
lines changed

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

Lines changed: 113 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -167,54 +167,86 @@ class ModelSynchronizer(
167167
targetParent: IWritableNode,
168168
forceSyncDescendants: Boolean,
169169
) {
170-
val sourceNodes = getFilteredSourceChildren(sourceParent, role)
171-
val targetNodes = getFilteredTargetChildren(targetParent, role)
172-
val associatedChildren = associateChildren(sourceNodes, targetNodes)
170+
var forceSyncDescendants = forceSyncDescendants
171+
var associatedChildren = associateChildren(sourceParent, targetParent, role)
173172

174173
// optimization that uses the bulk operation .syncNewChildren
175174
if (associatedChildren.all { it.hasToCreate() }) {
176-
targetParent.syncNewChildren(role, -1, sourceNodes.map { NewNodeSpec(it) })
177-
.zip(sourceNodes)
178-
.forEach { (newChild, sourceChild) ->
179-
nodeAssociation.associate(sourceChild, newChild)
180-
synchronizeNode(sourceChild, newChild, forceSyncDescendants = true)
181-
}
182-
return
175+
forceSyncDescendants = true
176+
runSafe {
177+
val newChildren = targetParent
178+
.syncNewChildren(role, -1, associatedChildren.map { NewNodeSpec(it.getSource()) })
179+
newChildren
180+
.zip(associatedChildren.map { it.getSource() })
181+
.forEach { (newChild, sourceChild) ->
182+
runSafe {
183+
nodeAssociation.associate(sourceChild, newChild)
184+
synchronizeNode(sourceChild, newChild, forceSyncDescendants = forceSyncDescendants)
185+
}
186+
}
187+
}.onSuccess {
188+
return
189+
}.onFailure {
190+
// Some children may have been created successfully.
191+
associatedChildren = associateChildren(sourceParent, targetParent, role)
192+
// Continue with trying to sync the remaining ones individually.
193+
}
183194
}
184195

185196
val isOrdered = targetParent.isOrdered(role)
186197

187198
// optimization for when there is no change in the child list
188199
if (associatedChildren.all { it.alreadyMatches(isOrdered) }) {
189200
associatedChildren.forEach {
190-
synchronizeNode(it.getSource(), it.getTarget(), forceSyncDescendants)
201+
runSafe {
202+
synchronizeNode(it.getSource(), it.getTarget(), forceSyncDescendants)
203+
}
191204
}
192205
return
193206
}
194207

195-
val unusedTargetChildren: List<IWritableNode> = associatedChildren
196-
.asSequence()
197-
.filter { it.hasToDelete() }
198-
.map { it.getTarget() }
199-
.toList()
200-
201-
nodesToRemove += unusedTargetChildren
202-
208+
// Recursive sync is done at the end because they may apply move operations that mutate
209+
// the currently iterated list.
203210
val recursiveSyncTasks = ArrayList<RecursiveSyncTask>()
204211

205-
for (associatedChild in associatedChildren) {
206-
if (associatedChild.hasToCreate()) {
207-
val newChild = targetParent.syncNewChild(role, associatedChild.sourceIndex, NewNodeSpec(associatedChild.getSource()))
208-
nodeAssociation.associate(associatedChild.getSource(), newChild)
209-
recursiveSyncTasks += RecursiveSyncTask(associatedChild.getSource(), newChild, true)
210-
} else if (associatedChild.hasToMove(isOrdered)) {
211-
targetParent.moveChild(role, associatedChild.sourceIndex, associatedChild.getTarget())
212-
nodesToRemove.remove(associatedChild.getTarget())
213-
recursiveSyncTasks += RecursiveSyncTask(associatedChild.getSource(), associatedChild.getTarget(), false)
214-
} else if (associatedChild.hasToDelete()) {
215-
// no sync between child nodes needed
216-
} else {
217-
recursiveSyncTasks += RecursiveSyncTask(associatedChild.getSource(), associatedChild.getTarget(), false)
212+
// Since nodes are removed at the end of the sync, we have to adjust the index for add/move operations.
213+
// We could just use `sourceIndex`, but that would result in unnecessary move operations.
214+
val orderBeforeRemove = associatedChildren.sortedWith(
215+
compareBy({
216+
when (it.getOperationType(isOrdered)) {
217+
AssociatedChild.OperationType.REMOVE -> it.targetIndex!!
218+
else -> it.sourceIndex!!
219+
}
220+
}, {
221+
when (it.getOperationType(isOrdered)) {
222+
AssociatedChild.OperationType.REMOVE -> 0
223+
else -> 1
224+
}
225+
}),
226+
)
227+
228+
for ((targetIndex, associatedChild) in orderBeforeRemove.withIndex()) {
229+
when (associatedChild.getOperationType(isOrdered)) {
230+
AssociatedChild.OperationType.CREATE -> {
231+
val newChild = targetParent.syncNewChild(role, targetIndex, NewNodeSpec(associatedChild.getSource()))
232+
nodeAssociation.associate(associatedChild.getSource(), newChild)
233+
recursiveSyncTasks += RecursiveSyncTask(associatedChild.getSource(), newChild, isNew = true)
234+
}
235+
AssociatedChild.OperationType.REMOVE -> {
236+
nodesToRemove += associatedChild.getTarget()
237+
}
238+
AssociatedChild.OperationType.MOVE_SAME_CONTAINMENT -> {
239+
targetParent.moveChild(role, targetIndex, associatedChild.getTarget())
240+
recursiveSyncTasks += RecursiveSyncTask(associatedChild.getSource(), associatedChild.getTarget(), isNew = false)
241+
}
242+
AssociatedChild.OperationType.MOVE_DIFFERENT_CONTAINMENT -> {
243+
targetParent.moveChild(role, targetIndex, associatedChild.getTarget())
244+
recursiveSyncTasks += RecursiveSyncTask(associatedChild.getSource(), associatedChild.getTarget(), isNew = false)
245+
nodesToRemove.remove(associatedChild.getTarget())
246+
}
247+
AssociatedChild.OperationType.ALREADY_MATCHES -> {
248+
recursiveSyncTasks += RecursiveSyncTask(associatedChild.getSource(), associatedChild.getTarget(), isNew = false)
249+
}
218250
}
219251
}
220252

@@ -248,38 +280,73 @@ class ModelSynchronizer(
248280
}
249281
}
250282

283+
private fun associateChildren(sourceParent: IReadableNode, targetParent: IWritableNode, role: IChildLinkReference): List<AssociatedChild> {
284+
val sourceNodes = getFilteredSourceChildren(sourceParent, role)
285+
val targetNodes = getFilteredTargetChildren(targetParent, role)
286+
return associateChildren(sourceNodes, targetNodes)
287+
}
288+
251289
private fun associateChildren(sourceChildren: List<IReadableNode>, targetChildren: List<IWritableNode>): List<AssociatedChild> {
252290
val unassociatedTargetNodes = targetChildren.withIndex().toMutableList()
253291
return sourceChildren.mapIndexed { sourceIndex, sourceChild ->
254292
val foundAt = unassociatedTargetNodes.indexOfFirst { targetChild ->
255293
nodeAssociation.matches(sourceChild, targetChild.value)
256294
}
257295
if (foundAt == -1) {
258-
AssociatedChild(sourceIndex, -1, sourceChild, null, nodeAssociation.resolveTarget(sourceChild))
296+
AssociatedChild(sourceIndex, null, sourceChild, null, nodeAssociation.resolveTarget(sourceChild))
259297
} else {
260298
val foundTarget = unassociatedTargetNodes.removeAt(foundAt)
261299
AssociatedChild(sourceIndex, foundTarget.index, sourceChild, foundTarget.value, null)
262300
}
263-
} + unassociatedTargetNodes.map { AssociatedChild(-1, it.index, null, it.value, null) }
301+
} + unassociatedTargetNodes.map { AssociatedChild(null, it.index, null, it.value, null) }
264302
}
265303

266304
private class AssociatedChild(
267-
val sourceIndex: Int,
268-
private val targetIndex: Int,
305+
val sourceIndex: Int?,
306+
val targetIndex: Int?,
269307
private val source: IReadableNode?,
270-
private val existingTarget: IWritableNode?,
271-
private val resolvedTarget: IWritableNode?,
308+
private val existingTargetInSameContainment: IWritableNode?,
309+
private val existingTargetInDifferentContainment: IWritableNode?,
272310
) {
273-
fun hasToCreate() = existingTarget == null && resolvedTarget == null
274-
fun hasToMoveFromDifferentContainment() = source != null && resolvedTarget != null
275-
fun hasToMoveWithinSameContainment() = source != null && existingTarget != null
276-
fun hasToMove(ordered: Boolean) = hasToMoveFromDifferentContainment() || ordered && hasToMoveWithinSameContainment()
277-
fun hasToDelete() = source == null
278-
fun alreadyMatchesOrdered() = source != null && existingTarget != null && sourceIndex == targetIndex
279-
fun alreadyMatchesUnordered() = source != null && existingTarget != null
311+
var currentTargetIndex: Int? = targetIndex
312+
313+
fun hasToCreate() = getOperationType(true) == OperationType.CREATE
314+
fun hasToMoveFromDifferentContainment() = getOperationType(true) == OperationType.MOVE_DIFFERENT_CONTAINMENT
315+
fun hasToMoveWithinSameContainment(ordered: Boolean) = getOperationType(ordered) == OperationType.MOVE_SAME_CONTAINMENT
316+
fun hasToMove(ordered: Boolean) = hasToMoveFromDifferentContainment() || hasToMoveWithinSameContainment(ordered)
317+
fun hasToDelete() = getOperationType(true) == OperationType.REMOVE
318+
fun alreadyMatchesOrdered() = alreadyMatchesUnordered() && sourceIndex == targetIndex
319+
fun alreadyMatchesUnordered() = source != null && existingTargetInSameContainment != null
280320
fun alreadyMatches(ordered: Boolean) = if (ordered) alreadyMatchesOrdered() else alreadyMatchesUnordered()
281-
fun getTarget() = existingTarget ?: resolvedTarget!!
321+
322+
fun getTarget() = existingTargetInSameContainment ?: existingTargetInDifferentContainment!!
282323
fun getSource() = source!!
324+
325+
fun getOperationType(ordered: Boolean): OperationType {
326+
return if (source == null) {
327+
OperationType.REMOVE
328+
} else {
329+
if (existingTargetInSameContainment != null) {
330+
if (ordered) {
331+
OperationType.MOVE_SAME_CONTAINMENT
332+
} else {
333+
OperationType.ALREADY_MATCHES
334+
}
335+
} else if (existingTargetInDifferentContainment != null) {
336+
OperationType.MOVE_DIFFERENT_CONTAINMENT
337+
} else {
338+
OperationType.CREATE
339+
}
340+
}
341+
}
342+
343+
enum class OperationType {
344+
CREATE,
345+
REMOVE,
346+
MOVE_SAME_CONTAINMENT,
347+
MOVE_DIFFERENT_CONTAINMENT,
348+
ALREADY_MATCHES,
349+
}
283350
}
284351

285352
inner class PendingReference(val sourceNode: IReadableNode, val targetNode: IWritableNode, val role: IReferenceLinkReference) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ abstract class AbstractModelSyncTest {
344344
assertNodeChildOrderConformsToSpec(expectedData.root.children[1], parent1)
345345

346346
val expectedOperations: Map<KClass<out IOperation>, Int> = mapOf(
347-
MoveNodeOp::class to 5,
347+
MoveNodeOp::class to 6,
348348
AddNewChildOp::class to 2,
349349
SetPropertyOp::class to 2, // original ids for new nodes
350350
DeleteNodeOp::class to 1,
@@ -578,7 +578,7 @@ abstract class AbstractModelSyncTest {
578578
val expectedOperations: Map<KClass<out IOperation>, Int> = mapOf(
579579
SetPropertyOp::class to 5,
580580
SetReferenceOp::class to 3,
581-
MoveNodeOp::class to 6, // could be done in 5, but finding that optimization makes the sync algorithm slower
581+
MoveNodeOp::class to 4,
582582
AddNewChildOp::class to 1,
583583
DeleteNodeOp::class to 1,
584584
)

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import org.modelix.model.lazy.CLTree
1414
import org.modelix.model.lazy.ObjectStoreCache
1515
import org.modelix.model.operations.AddNewChildOp
1616
import org.modelix.model.operations.AddNewChildrenOp
17+
import org.modelix.model.operations.DeleteNodeOp
1718
import org.modelix.model.operations.OTBranch
1819
import org.modelix.model.persistent.MapBasedStore
1920
import org.modelix.model.test.RandomModelChangeGenerator
2021
import kotlin.js.JsName
2122
import kotlin.random.Random
2223
import kotlin.test.Test
2324
import kotlin.test.assertEquals
25+
import kotlin.test.assertIs
2426
import kotlin.test.assertTrue
2527

2628
open class ModelSynchronizerTest : AbstractModelSyncTest() {
@@ -78,6 +80,39 @@ open class ModelSynchronizerTest : AbstractModelSyncTest() {
7880
}
7981
}
8082

83+
@Test
84+
fun deleting_a_node_creates_a_single_operation() {
85+
val sourceBranch = createLocalBranch().apply {
86+
runWrite {
87+
getRootNode().asWritableNode().apply {
88+
setPropertyValue(NodeData.ID_PROPERTY_REF, "root")
89+
addNewChild(IChildLinkReference.fromName("test"), -1, NullConcept.getReference()).setPropertyValue(NodeData.ID_PROPERTY_REF, "node1")
90+
addNewChild(IChildLinkReference.fromName("test"), -1, NullConcept.getReference()).setPropertyValue(NodeData.ID_PROPERTY_REF, "node3")
91+
addNewChild(IChildLinkReference.fromName("test"), -1, NullConcept.getReference()).setPropertyValue(NodeData.ID_PROPERTY_REF, "node4")
92+
}
93+
}
94+
}.toOTBranch()
95+
96+
val targetBranch = createLocalBranch().apply {
97+
runWrite {
98+
getRootNode().asWritableNode().apply {
99+
setPropertyValue(NodeData.ID_PROPERTY_REF, "root")
100+
addNewChild(IChildLinkReference.fromName("test"), -1, NullConcept.getReference()).setPropertyValue(NodeData.ID_PROPERTY_REF, "node1")
101+
addNewChild(IChildLinkReference.fromName("test"), -1, NullConcept.getReference()).setPropertyValue(NodeData.ID_PROPERTY_REF, "node2")
102+
addNewChild(IChildLinkReference.fromName("test"), -1, NullConcept.getReference()).setPropertyValue(NodeData.ID_PROPERTY_REF, "node3")
103+
addNewChild(IChildLinkReference.fromName("test"), -1, NullConcept.getReference()).setPropertyValue(NodeData.ID_PROPERTY_REF, "node4")
104+
}
105+
}
106+
}.toOTBranch()
107+
108+
runTest(sourceBranch, targetBranch) {
109+
assertEquals(3, targetBranch.getRootNode().allChildren.count())
110+
val operations = targetBranch.getPendingChanges().first
111+
assertEquals(1, operations.size, "Operations: $operations")
112+
assertIs<DeleteNodeOp>(operations.single().getOriginalOp())
113+
}
114+
}
115+
81116
private fun createLocalBranch() = ModelFacade.toLocalBranch(ModelFacade.newLocalTree())
82117

83118
override fun runTest(initialData: ModelData, expectedData: ModelData, assertions: OTBranch.() -> Unit) {

0 commit comments

Comments
 (0)