Skip to content

Commit e87a86f

Browse files
committed
feat(model-client): method for querying the history as sessions
1 parent 49de28f commit e87a86f

File tree

3 files changed

+226
-10
lines changed

3 files changed

+226
-10
lines changed

model-client/src/commonMain/kotlin/org/modelix/model/client2/IModelClientV2.kt

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import org.modelix.model.lazy.RepositoryId
1414
import org.modelix.model.server.api.RepositoryConfig
1515
import org.modelix.modelql.core.IMonoStep
1616
import kotlin.time.Duration
17+
import kotlin.time.Duration.Companion.minutes
1718

1819
/**
1920
* This interface is meant exclusively for model client usage.
@@ -110,10 +111,23 @@ interface IModelClientV2 {
110111
fun getFrontendUrl(branch: BranchReference): Url
111112

112113
/**
113-
* @param headVersion starting point for history computations. For a paginated view this value should be the same
114-
* and the value for [skip] should be incremented instead. Only then it's guaranteed that the returned list
115-
* is complete.
116-
* @param timeRange return only intervals in this time range
114+
* Splits the history between versions where the time difference is greater or equal to [delay].
115+
*
116+
* @param headVersion starting point for history computations.
117+
* @param timeRange return versions in this time range only
118+
* @param delay time between two changes after which it is considered to be a new session
119+
*/
120+
suspend fun getHistorySessions(
121+
repositoryId: RepositoryId,
122+
headVersion: ObjectHash,
123+
timeRange: ClosedRange<Instant>? = null,
124+
delay: Duration = 5.minutes,
125+
): List<HistoryInterval>
126+
127+
/**
128+
*
129+
* @param headVersion starting point for history computations.
130+
* @param timeRange return versions in this time range only
117131
* @param interval splits the timeline into equally sized intervals and returns a summary of the contained versions
118132
*/
119133
suspend fun getHistoryIntervals(
@@ -123,6 +137,12 @@ interface IModelClientV2 {
123137
interval: Duration,
124138
): List<HistoryInterval>
125139

140+
/**
141+
* A paginated view on the list of all versions in the history sorted by their timestamp. Latest version first.
142+
* @param headVersion starting point for history computations. For a paginated view this value should be the same
143+
* and the value for [skip] should be incremented instead. Only then it's guaranteed that the returned list
144+
* is complete.
145+
*/
126146
suspend fun getHistoryRange(
127147
repositoryId: RepositoryId,
128148
headVersion: ObjectHash,
@@ -131,17 +151,17 @@ interface IModelClientV2 {
131151
): List<IVersion>
132152
}
133153

134-
data class HistoryResponse(
135-
val entries: List<HistoryInterval>,
136-
val nextVersions: List<ObjectHash>,
137-
)
138-
154+
/**
155+
* A summary of a range of versions.
156+
*/
139157
data class HistoryInterval(
140158
val firstVersionHash: ObjectHash,
141159
val lastVersionHash: ObjectHash,
160+
/**
161+
* Number of versions contained in this interval.
162+
*/
142163
val size: Long,
143164
val minTime: Instant,
144165
val maxTime: Instant,
145166
val authors: Set<String>,
146-
// val headOfBranch: Set<BranchReference>,
147167
)

model-client/src/commonMain/kotlin/org/modelix/model/client2/ModelClientV2.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,50 @@ class ModelClientV2(
333333
}
334334
}
335335

336+
/**
337+
* Splits the history into intervals where the time difference between two versions is less or equal to [delay].
338+
*/
339+
override suspend fun getHistorySessions(
340+
repositoryId: RepositoryId,
341+
headVersion: ObjectHash,
342+
timeRange: ClosedRange<Instant>?,
343+
delay: Duration,
344+
): List<HistoryInterval> {
345+
val index: Object<HistoryIndexNode> = getHistoryIndex(repositoryId, headVersion)
346+
val sessions = ArrayList<HistoryInterval>()
347+
var previousMinTime = Instant.fromEpochSeconds(Long.MAX_VALUE)
348+
349+
// In the worst case two adjacent intervals contain a single entry directly at the border.
350+
// The maximum difference between these two entries is less than two times the interval.
351+
val interval = delay / 2
352+
353+
index.data.splitAtInterval(interval, timeRange).iterateSuspending(index.graph) {
354+
if (previousMinTime - it.maxTime >= delay) {
355+
sessions += HistoryInterval(
356+
firstVersionHash = it.firstVersion.getHash(),
357+
lastVersionHash = it.lastVersion.getHash(),
358+
size = it.size,
359+
minTime = it.minTime,
360+
maxTime = it.maxTime,
361+
authors = it.authors,
362+
)
363+
} else {
364+
val entry = sessions[sessions.lastIndex]
365+
sessions[sessions.lastIndex] = HistoryInterval(
366+
firstVersionHash = it.firstVersion.getHash(),
367+
lastVersionHash = entry.lastVersionHash,
368+
size = entry.size + it.size,
369+
minTime = minOf(entry.minTime, it.minTime),
370+
maxTime = maxOf(entry.maxTime, it.maxTime),
371+
authors = entry.authors + it.authors,
372+
)
373+
}
374+
previousMinTime = it.minTime
375+
}
376+
377+
return sessions
378+
}
379+
336380
override suspend fun getHistoryIntervals(
337381
repositoryId: RepositoryId,
338382
headVersion: ObjectHash,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package org.modelix.model.server
2+
3+
import io.ktor.server.testing.ApplicationTestBuilder
4+
import io.ktor.server.testing.testApplication
5+
import mu.KotlinLogging
6+
import org.modelix.model.IVersion
7+
import org.modelix.model.client2.HistoryInterval
8+
import org.modelix.model.client2.IModelClientV2
9+
import org.modelix.model.historyAsSequence
10+
import org.modelix.model.lazy.RepositoryId
11+
import org.modelix.model.server.api.RepositoryConfig
12+
import org.modelix.model.server.handlers.IdsApiImpl
13+
import org.modelix.model.server.handlers.ModelReplicationServer
14+
import org.modelix.model.server.handlers.RepositoriesManager
15+
import org.modelix.model.server.store.InMemoryStoreClient
16+
import kotlin.random.Random
17+
import kotlin.test.Test
18+
import kotlin.test.assertEquals
19+
import kotlin.time.Duration
20+
import kotlin.time.Duration.Companion.minutes
21+
import kotlin.time.Duration.Companion.seconds
22+
23+
private val LOG = KotlinLogging.logger { }
24+
25+
class HistoryIndexSessionsTest {
26+
27+
private lateinit var statistics: StoreClientWithStatistics
28+
private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
29+
application {
30+
try {
31+
installDefaultServerPlugins()
32+
statistics = StoreClientWithStatistics(InMemoryStoreClient())
33+
val repoManager = RepositoriesManager(statistics)
34+
ModelReplicationServer(repoManager).init(this)
35+
IdsApiImpl(repoManager).init(this)
36+
} catch (ex: Throwable) {
37+
LOG.error("", ex)
38+
}
39+
}
40+
block()
41+
}
42+
43+
@Test fun interval_0_201_1() = runIntervalTest(0, 201, 1.minutes)
44+
45+
@Test fun interval_0_201_2() = runIntervalTest(0, 201, 2.minutes)
46+
47+
@Test fun interval_0_201_3() = runIntervalTest(0, 201, 3.minutes)
48+
49+
@Test fun interval_0_201_4() = runIntervalTest(0, 201, 4.minutes)
50+
51+
@Test fun interval_0_201_5() = runIntervalTest(0, 201, 5.minutes)
52+
53+
@Test fun interval_0_201_6() = runIntervalTest(0, 201, 6.minutes)
54+
55+
@Test fun interval_0_201_7() = runIntervalTest(0, 201, 7.minutes)
56+
57+
@Test fun interval_0_201_8() = runIntervalTest(0, 201, 8.minutes)
58+
59+
@Test fun interval_0_201_9() = runIntervalTest(0, 201, 9.minutes)
60+
61+
@Test fun interval_0_201_10() = runIntervalTest(0, 201, 10.minutes)
62+
63+
@Test fun interval_0_201_11() = runIntervalTest(0, 201, 11.minutes)
64+
65+
@Test fun interval_0_1_5() = runIntervalTest(0, 1, 5.minutes)
66+
67+
@Test fun interval_0_2_5() = runIntervalTest(0, 2, 5.minutes)
68+
69+
@Test fun interval_0_3_5() = runIntervalTest(0, 3, 5.minutes)
70+
71+
@Test fun interval_0_4_5() = runIntervalTest(0, 4, 5.minutes)
72+
73+
@Test fun interval_1_4_5() = runIntervalTest(1, 4, 5.minutes)
74+
75+
@Test fun interval_2_4_5() = runIntervalTest(2, 4, 5.minutes)
76+
77+
@Test fun interval_3_4_5() = runIntervalTest(3, 4, 5.minutes)
78+
79+
@Test fun interval_4_4_5() = runIntervalTest(4, 4, 5.minutes)
80+
81+
@Test fun interval_5_4_5() = runIntervalTest(5, 4, 5.minutes)
82+
83+
private fun runIntervalTest(skip: Int, limit: Int, delay: Duration) = runTest {
84+
val rand = Random(8923345)
85+
val modelClient: IModelClientV2 = createModelClient()
86+
val repositoryId = RepositoryId("repo1")
87+
val branchRef = repositoryId.getBranchReference()
88+
val initialVersion = modelClient.initRepository(RepositoryConfig(repositoryId = repositoryId.id, repositoryName = repositoryId.id, modelId = "61bd6cb0-33ff-45d8-9d1b-2149fdb01d16"))
89+
var currentVersion = initialVersion
90+
91+
var nextTimestamp = currentVersion.getTimestamp()!! + rand.nextInt(0, 3).seconds
92+
repeat(10) {
93+
repeat(10) {
94+
run {
95+
val newVersion = IVersion.builder()
96+
.baseVersion(currentVersion)
97+
.tree(currentVersion.getModelTree())
98+
.author("user1")
99+
.time(nextTimestamp)
100+
.build()
101+
currentVersion = modelClient.push(branchRef, newVersion, currentVersion)
102+
}
103+
nextTimestamp += rand.nextInt(0, 3).seconds
104+
run {
105+
val newVersion = IVersion.builder()
106+
.baseVersion(currentVersion)
107+
.tree(currentVersion.getModelTree())
108+
.author("user2")
109+
.time(nextTimestamp)
110+
.build()
111+
currentVersion = modelClient.push(branchRef, newVersion, currentVersion)
112+
}
113+
nextTimestamp += rand.nextInt(0, 3).seconds
114+
}
115+
nextTimestamp += rand.nextInt(1 * 60, 10 * 60).seconds
116+
}
117+
118+
val expectedHistory = currentVersion.historyAsSequence().toList().reversed()
119+
120+
val expectedIntervals = expectedHistory.fold(listOf<HistoryInterval>()) { acc, version ->
121+
if (acc.isEmpty() || version.getTimestamp()!! - acc.last().maxTime >= delay) {
122+
acc + HistoryInterval(
123+
firstVersionHash = version.getObjectHash(),
124+
lastVersionHash = version.getObjectHash(),
125+
size = 1,
126+
minTime = version.getTimestamp()!!,
127+
maxTime = version.getTimestamp()!!,
128+
authors = setOfNotNull(version.getAuthor()),
129+
)
130+
} else {
131+
val lastInterval = acc.last()
132+
acc.dropLast(1) + HistoryInterval(
133+
firstVersionHash = lastInterval.firstVersionHash,
134+
lastVersionHash = version.getObjectHash(),
135+
size = lastInterval.size + 1,
136+
minTime = lastInterval.minTime,
137+
maxTime = version.getTimestamp()!!,
138+
authors = lastInterval.authors + listOfNotNull(version.getAuthor()),
139+
)
140+
}
141+
}.reversed().drop(skip).take(limit)
142+
143+
val timeRange = (expectedIntervals.minOf { it.minTime })..(expectedIntervals.maxOf { it.maxTime })
144+
val history = modelClient.getHistorySessions(
145+
repositoryId = repositoryId,
146+
headVersion = currentVersion.getObjectHash(),
147+
timeRange = timeRange,
148+
delay = delay,
149+
)
150+
assertEquals(expectedIntervals, history)
151+
}
152+
}

0 commit comments

Comments
 (0)