Skip to content

Commit e66ac56

Browse files
author
Oleksandr Dzhychko
committed
fix(model-datastructure): fix merge algorithm
Fix topological sort in `LinearHistory`. Add fast-forward merge in `VersionMerger`. The fast-forward merge is needed with the new topological sorting.
1 parent 07787a1 commit e66ac56

File tree

4 files changed

+143
-107
lines changed

4 files changed

+143
-107
lines changed
Lines changed: 43 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,60 @@
11
package org.modelix.model
22

33
import org.modelix.model.lazy.CLVersion
4-
import org.modelix.model.lazy.IDeserializingKeyValueStore
5-
import org.modelix.model.lazy.KVEntryReference
6-
import org.modelix.model.persistent.CPVersion
74

8-
/**
9-
* Was introduced in https://github.com/modelix/modelix/commit/19c74bed5921028af3ac3ee9d997fc1c4203ad44
10-
* together with the UndoOp. The idea is that an undo should only revert changes if there is no other change that relies
11-
* on it. In that case the undo should do nothing, to not indirectly undo newer changes.
12-
* For example, if you added a node and someone else started changing properties on the that node, your undo should not
13-
* remove the node to not lose the property changes.
14-
* This requires the versions to be ordered in a way that the undo appears later.
15-
*/
165
class LinearHistory(val baseVersionHash: String?) {
176

18-
val version2directDescendants: MutableMap<Long, Set<Long>> = HashMap()
19-
val versions: MutableMap<Long, CLVersion> = LinkedHashMap()
20-
217
/**
22-
* @param fromVersions it is assumed that the versions are sorted by the oldest version first. When merging a new
23-
* version into an existing one the new version should appear after the existing one. The resulting order
24-
* will prefer existing versions to new ones, meaning during the conflict resolution the existing changes
25-
* have a higher probability of surviving.
26-
* @returns oldest version first
8+
* Order all versions descending from any versions in [[fromVersions]] topologically.
9+
* This means that a version must come after all its descendants.
10+
* Returns the ordered versions starting with the earliest version.
2711
*/
28-
fun load(vararg fromVersions: CLVersion): List<CLVersion> {
29-
for (fromVersion in fromVersions) {
30-
collect(fromVersion)
31-
}
32-
33-
var result: List<Long> = emptyList()
34-
35-
for (version in versions.values.filter { !it.isMerge() }.sortedBy { it.id }) {
36-
val descendantIds = collectAllDescendants(version.id).filter { !versions[it]!!.isMerge() }.sorted().toSet()
37-
val idsInResult = result.toHashSet()
38-
if (idsInResult.contains(version.id)) {
39-
result =
40-
result +
41-
descendantIds.filter { !idsInResult.contains(it) }
42-
} else {
43-
result =
44-
result.filter { !descendantIds.contains(it) } +
45-
version.id +
46-
result.filter { descendantIds.contains(it) } +
47-
descendantIds.filter { !idsInResult.contains(it) }
12+
fun loadLazy(vararg fromVersions: CLVersion) = sequence {
13+
// The algorithm sorts the versions topologically.
14+
// It performs a depth-first search.
15+
// It is implemented as an iterative algorithm with a stack.
16+
17+
val stack = ArrayDeque<CLVersion>()
18+
val visited = mutableSetOf<CLVersion>()
19+
20+
// Ensure deterministic merging,
21+
// by putting versions with lower id before versions with higher id.
22+
fromVersions.sortedBy { it.id }.forEach { fromVersion ->
23+
// Not putting fromVersions directly on the stack and checking visited.contains(fromVersion) ,
24+
// ensures the algorithm terminates if one version in `fromVersion`
25+
// is a descendant of another version in `fromVersion`
26+
if (!visited.contains(fromVersion)) {
27+
stack.addLast(fromVersion)
4828
}
49-
}
50-
return result.map { versions[it]!! }
51-
}
52-
53-
private fun collectAllDescendants(root: Long): Set<Long> {
54-
val result = LinkedHashSet<Long>()
55-
var previousSize = 0
56-
result += root
57-
58-
while (previousSize != result.size) {
59-
val nextElements = result.asSequence().drop(previousSize).toList()
60-
previousSize = result.size
61-
for (ancestor in nextElements) {
62-
version2directDescendants[ancestor]?.let { result += it }
63-
}
64-
}
65-
66-
return result.drop(1).toSet()
67-
}
68-
69-
private fun collect(root: CLVersion) {
70-
if (root.getContentHash() == baseVersionHash) return
71-
72-
var previousSize = versions.size
73-
versions[root.id] = root
74-
75-
while (previousSize != versions.size) {
76-
val nextElements = versions.asSequence().drop(previousSize).map { it.value }.toList()
77-
previousSize = versions.size
78-
79-
for (descendant in nextElements) {
80-
val ancestors = if (descendant.isMerge()) {
81-
sequenceOf(
82-
getVersion(descendant.data!!.mergedVersion1!!, descendant.store),
83-
getVersion(descendant.data!!.mergedVersion2!!, descendant.store),
84-
)
29+
while (stack.isNotEmpty()) {
30+
val version = stack.last()
31+
val versionWasVisited = !visited.add(version)
32+
if (versionWasVisited) {
33+
stack.removeLast()
34+
if (!version.isMerge()) {
35+
yield(version)
36+
}
37+
}
38+
val descendants = if (version.isMerge()) {
39+
// Put version 1 last, so that is processed first.
40+
// We are using a stack and the last version is viewed first.
41+
listOf(version.getMergedVersion2()!!, version.getMergedVersion1()!!)
8542
} else {
86-
sequenceOf(descendant.baseVersion)
87-
}.filterNotNull().filter { it.getContentHash() != baseVersionHash }.toList()
88-
for (ancestor in ancestors) {
89-
versions[ancestor.id] = ancestor
90-
version2directDescendants[ancestor.id] = (version2directDescendants[ancestor.id] ?: emptySet()) + setOf(descendant.id)
43+
listOfNotNull(version.baseVersion)
9144
}
45+
// Ignore descendant, if it is a base version.
46+
val relevantDescendants = descendants.filter { it.getContentHash() != baseVersionHash }
47+
// Ignore already visited descendants.
48+
val nonCheckedDescendants = relevantDescendants.filterNot { visited.contains(it) }
49+
nonCheckedDescendants.forEach { stack.addLast(it) }
9250
}
9351
}
9452
}
9553

96-
private fun getVersion(hash: KVEntryReference<CPVersion>, store: IDeserializingKeyValueStore): CLVersion {
97-
return CLVersion(hash.getValue(store), store)
54+
/**
55+
* Same as [[loadLazy]], but returning as a list instead of a lazy sequence.
56+
*/
57+
fun load(vararg fromVersions: CLVersion): List<CLVersion> {
58+
return loadLazy(*fromVersions).toList()
9859
}
9960
}

model-datastructure/src/commonMain/kotlin/org/modelix/model/VersionMerger.kt

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,30 +45,27 @@ class VersionMerger(private val storeCache: IDeserializingKeyValueStore, private
4545
}
4646
}
4747

48-
private fun collectLatestNonMerges(version: CLVersion?, visited: MutableSet<String>, result: MutableSet<Long>) {
49-
if (version == null) return
50-
if (!visited.add(version.getContentHash())) return
51-
if (version.isMerge()) {
52-
collectLatestNonMerges(version.getMergedVersion1(), visited, result)
53-
collectLatestNonMerges(version.getMergedVersion2(), visited, result)
54-
} else {
55-
result.add(version.id)
56-
}
57-
}
58-
5948
protected fun mergeHistory(leftVersion: CLVersion, rightVersion: CLVersion): CLVersion {
6049
if (leftVersion.hash == rightVersion.hash) return leftVersion
61-
val commonBase = Companion.commonBaseVersion(leftVersion, rightVersion)
50+
val commonBase = commonBaseVersion(leftVersion, rightVersion)
6251
if (commonBase?.hash == leftVersion.hash) return rightVersion
6352
if (commonBase?.hash == rightVersion.hash) return leftVersion
6453

65-
val leftNonMerges = HashSet<Long>().also { collectLatestNonMerges(leftVersion, HashSet(), it) }
66-
val rightNonMerges = HashSet<Long>().also { collectLatestNonMerges(rightVersion, HashSet(), it) }
54+
val leftNonMerges = LinearHistory(commonBase?.hash).loadLazy(leftVersion).toSet()
55+
val rightNonMerges = LinearHistory(commonBase?.hash).loadLazy(rightVersion).toSet()
6756
if (leftNonMerges == rightNonMerges) {
6857
// If there is no actual change on both sides, but they just did the same merge, we have to pick one
6958
// of them, otherwise both sides will continue creating merges forever.
7059
return if (leftVersion.id < rightVersion.id) leftVersion else rightVersion
7160
}
61+
if (leftNonMerges.containsAll(rightNonMerges)) {
62+
// No merge needed, fast-forward to the left version
63+
return leftVersion
64+
}
65+
if (rightNonMerges.containsAll(leftNonMerges)) {
66+
// No merge needed, fast-forward to the right version
67+
return rightVersion
68+
}
7269

7370
val versionsToApply = LinearHistory(commonBase?.hash).load(leftVersion, rightVersion)
7471

model-datastructure/src/commonTest/kotlin/LinearHistoryTest.kt

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import org.modelix.model.operations.IOperation
2323
import org.modelix.model.persistent.MapBaseStore
2424
import kotlin.test.Test
2525
import kotlin.test.assertEquals
26+
import kotlin.test.assertTrue
2627

2728
class LinearHistoryTest {
2829
val initialTree = CLTree.builder(ObjectStoreCache(MapBaseStore())).repositoryId("LinearHistoryTest").build()
@@ -149,37 +150,90 @@ class LinearHistoryTest {
149150

150151
// val expected = SlowLinearHistory(v1000004d3.getContentHash()).load(v300000075, v1000004ee)
151152
val expected = listOf(
152-
v1000004d5,
153153
v200000353,
154+
v1000004d5,
155+
v200000356,
154156
v1000004d8,
157+
v200000359,
155158
v1000004dc,
156159
v1000004df,
157-
v1000004e2,
158-
v1000004e5,
159-
v1000004e7,
160-
v1000004e9,
161-
v200000356,
162-
v200000359,
163160
v20000035d,
164161
v20000035f,
162+
v1000004e2,
165163
v200000362,
164+
v1000004e5,
166165
v200000365,
166+
v1000004e7,
167167
v200000367,
168+
v1000004e9,
168169
v200000369,
169170
v20000036c,
170171
)
171172
assertHistory(v300000075, v1000004ee, expected)
172173
}
173174

175+
@Test
176+
fun correctHistoryIfIdsAreNotAscending() {
177+
val v1 = version(1, null)
178+
val v2 = version(2, v1)
179+
val v3 = version(3, v1)
180+
val v9 = version(9, v2)
181+
val v4 = merge(4, v2, v3)
182+
val v8 = version(8, v9)
183+
184+
val expected = listOf(v2, v3, v9, v8)
185+
assertHistory(v4, v8, expected)
186+
}
187+
174188
private fun assertHistory(v1: CLVersion, v2: CLVersion, expected: List<CLVersion>) {
175-
val actual = history(v1, v2)
176-
assertEquals(expected.map { it.id.toString(16) }, actual.map { it.id.toString(16) })
177-
assertEquals(expected, actual)
189+
val historyMergingFirstIntoSecond = history(v1, v2)
190+
val historyMeringSecondIntoFirst = history(v2, v1)
191+
assertEquals(
192+
historyMergingFirstIntoSecond.map { it.id.toString(16) },
193+
historyMeringSecondIntoFirst.map { it.id.toString(16) },
194+
)
195+
assertEquals(expected.map { it.id.toString(16) }, historyMergingFirstIntoSecond.map { it.id.toString(16) })
178196
}
179197

180198
private fun history(v1: CLVersion, v2: CLVersion): List<CLVersion> {
181199
val base = VersionMerger.commonBaseVersion(v1, v2)
182-
return LinearHistory(base?.getContentHash()).load(v1, v2)
200+
val history = LinearHistory(base?.getContentHash()).load(v1, v2)
201+
assertHistoryIsCorrect(history)
202+
return history
203+
}
204+
205+
private fun assertHistoryIsCorrect(history: List<CLVersion>) {
206+
history.forEach { version ->
207+
val versionIndex = history.indexOf(version)
208+
getDescendants(version).forEach { descendent ->
209+
val descendantIndex = history.indexOf(descendent)
210+
// A descendant might not be in history
211+
// (1) if it is a merge or
212+
// (2) if it is a descendant of a common version.
213+
// The descendantIndex is then -1.
214+
assertTrue(
215+
versionIndex > descendantIndex,
216+
"${version.id.toString(16)} must come after its descendant ${descendent.id.toString(16)} in ${history.map { it.id.toString() }} .",
217+
)
218+
}
219+
}
220+
}
221+
222+
private fun getChildren(version: CLVersion): List<CLVersion> {
223+
return if (version.isMerge()) {
224+
listOf(version.getMergedVersion1()!!, version.getMergedVersion2()!!)
225+
} else {
226+
listOfNotNull(version.baseVersion)
227+
}
228+
}
229+
230+
private fun getDescendants(version: CLVersion): MutableSet<CLVersion> {
231+
val descendants = mutableSetOf<CLVersion>()
232+
getChildren(version).forEach { descendant ->
233+
descendants.add(descendant)
234+
descendants.addAll(getDescendants(descendant))
235+
}
236+
return descendants
183237
}
184238

185239
private fun version(id: Long, base: CLVersion?): CLVersion {

model-datastructure/src/commonTest/kotlin/UndoTest.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,30 @@ class UndoTest {
3636

3737
@Test
3838
fun undo_random() {
39+
/*
40+
digraph G {
41+
1 [label="id=1"]
42+
2 [label="id=2\nv[0]"]
43+
3 [label="id=3\nv[1]"]
44+
4 [label="id=4\nv[2]"]
45+
undo [label="id=5\nversion_1_1u\n>>undo v[1]<<"]
46+
version_0_1 [label="id=30064771077\nversion_0_1"]
47+
version_2_1 [label="id=30064771078\nversion_2_1"]
48+
49+
2 -> 1
50+
3 -> 1
51+
4 -> 1
52+
undo -> 3
53+
version_0_1 -> 2
54+
version_0_1 -> 3
55+
version_2_1 -> 3
56+
version_2_1 -> 4
57+
version_0_1_1u -> version_0_1
58+
version_0_1_1u -> undo
59+
version_2_1_1u -> undo
60+
version_2_1_1u -> version_2_1
61+
}
62+
*/
3963
val idGenerator = IdGenerator.newInstance(7)
4064
val versionIdGenerator = IdGenerator.newInstance(0)
4165
val store = ObjectStoreCache(MapBaseStore())

0 commit comments

Comments
 (0)