Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
bin
.gradle/
**/build
/*/ignite/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
Expand All @@ -20,6 +21,7 @@ import org.modelix.model.api.INodeReference
import org.modelix.model.api.runSynchronized
import org.modelix.model.lazy.BranchReference
import org.modelix.model.lazy.CLVersion
import org.modelix.model.lazy.RepositoryId
import org.modelix.model.mutable.IGenericMutableModelTree
import org.modelix.model.mutable.IMutableModelTree
import org.modelix.model.mutable.INodeIdGenerator
Expand Down Expand Up @@ -56,27 +58,52 @@ import org.modelix.model.mutable.asModel
* Dispose should be called on this, as otherwise a regular polling will go on.
*
* @property client the model client to connect to the model server
* @property branchRef the model server branch to fetch the data from
* @property branchRef branch or repository reference
* @property providedScope the CoroutineScope to use for the suspendable tasks
* @property initialRemoteVersion the last version on the server from which we want to start the synchronization
*/
class ReplicatedModel(
val client: IModelClientV2,
val branchRef: BranchReference,
private val branchRefOrNull: BranchReference?,
val idGenerator: (TreeId) -> INodeIdGenerator<INodeReference>,
private val providedScope: CoroutineScope? = null,
initialRemoteVersion: CLVersion? = null,
repositoryId: RepositoryId? = null,
versionHash: String? = null,
) : Closeable {

constructor(
client: IModelClientV2,
branchRef: BranchReference,
idGenerator: (TreeId) -> INodeIdGenerator<INodeReference>,
providedScope: CoroutineScope? = null,
initialRemoteVersion: CLVersion? = null,
) : this(client, branchRef, idGenerator, providedScope, initialRemoteVersion, null, null)

val branchRef: BranchReference get() = branchRefOrNull ?: throw IllegalStateException("ReplicatedModel is in read-only version mode")

private val scope = providedScope ?: CoroutineScope(Dispatchers.Default)
private var state = State.New
private var localModel: LocalModel? = null
private val remoteVersion = RemoteVersion(client, branchRef, initialRemoteVersion)

private val remoteVersion: IRemoteVersion

private var pollingJob: Job? = null

init {
if (initialRemoteVersion != null) {
localModel = LocalModel(initialRemoteVersion, client.getIdGenerator(), idGenerator(initialRemoteVersion.getModelTree().getId())) { client.getUserId() }
}

if (branchRefOrNull != null) {
check(versionHash == null) { "Cannot provide both branchRef and versionHash" }
remoteVersion = RemoteVersionFromBranch(client, branchRefOrNull, initialRemoteVersion)
} else if (versionHash != null) {
val repoId = repositoryId ?: throw IllegalArgumentException("repositoryId is required when versionHash is provided")
remoteVersion = RemoteVersionFromHash(client, repoId, versionHash)
} else {
throw IllegalArgumentException("Either branchRef or versionHash must be provided")
}
}

private fun getLocalModel(): LocalModel = checkNotNull(localModel) { "Model is not initialized yet" }
Expand All @@ -92,7 +119,7 @@ class ReplicatedModel(
state = State.Starting

if (localModel == null) {
val initialVersion = remoteVersion.pull()
val initialVersion = remoteVersion.getInitialVersion()
localModel = LocalModel(initialVersion, client.getIdGenerator(), idGenerator(initialVersion.getModelTree().getId())) { client.getUserId() }
}

Expand All @@ -106,10 +133,10 @@ class ReplicatedModel(
remoteVersionReceived(newRemoteVersion, null)
nextDelayMs = 0
} catch (ex: CancellationException) {
LOG.debug { "Stop polling branch $branchRef after disposing." }
LOG.debug { "Stop polling after disposing." }
throw ex
} catch (ex: Throwable) {
LOG.error(ex) { "Failed polling branch $branchRef" }
LOG.error(ex) { "Failed polling" }
nextDelayMs = (nextDelayMs * 3 / 2).coerceIn(1000, 30000)
}
}
Expand All @@ -134,7 +161,9 @@ class ReplicatedModel(
}

suspend fun resetToServerVersion() {
getLocalModel().resetToVersion(client.pull(branchRef, lastKnownVersion = null).upcast())
// This delegates to remoteVersion which handles pull/load
val version = remoteVersion.getInitialVersion()
getLocalModel().resetToVersion(version)
}

fun isDisposed(): Boolean = state == State.Disposed
Expand Down Expand Up @@ -308,16 +337,22 @@ private class LocalModel(initialVersion: CLVersion, val versionIdGenerator: IIdG
}
}

private class RemoteVersion(
private interface IRemoteVersion {
suspend fun getInitialVersion(): CLVersion
suspend fun poll(): CLVersion
suspend fun push(version: CLVersion): CLVersion
}

private class RemoteVersionFromBranch(
val client: IModelClientV2,
val branchRef: BranchReference,
private var lastKnownRemoteVersion: CLVersion? = null,
) {
) : IRemoteVersion {
private val unconfirmedVersions: MutableSet<String> = LinkedHashSet()

fun getNumberOfUnconfirmed() = runSynchronized(unconfirmedVersions) { unconfirmedVersions.size }

suspend fun pull(): CLVersion {
override suspend fun getInitialVersion(): CLVersion {
return versionReceived(
client.pull(
branchRef,
Expand All @@ -332,11 +367,11 @@ private class RemoteVersion(
)
}

suspend fun poll(): CLVersion {
override suspend fun poll(): CLVersion {
return versionReceived(client.poll(branchRef, lastKnownVersion = lastKnownRemoteVersion).upcast())
}

suspend fun push(version: CLVersion): CLVersion {
override suspend fun push(version: CLVersion): CLVersion {
if (lastKnownRemoteVersion?.getContentHash() == version.getContentHash()) return version
runSynchronized(unconfirmedVersions) {
if (!unconfirmedVersions.add(version.getContentHash())) return version
Expand All @@ -359,4 +394,29 @@ private class RemoteVersion(
}
}

private class RemoteVersionFromHash(
val client: IModelClientV2,
val repositoryId: RepositoryId,
val versionHash: String,
private var lastKnownRemoteVersion: CLVersion? = null,
) : IRemoteVersion {

override suspend fun getInitialVersion(): CLVersion {
return client.loadVersion(
repositoryId,
versionHash,
lastKnownRemoteVersion,
).upcast().also { lastKnownRemoteVersion = it }
}

override suspend fun poll(): CLVersion {
// let's pretent to do something. The version is actually immutable and won't ever change…
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of using a ReplicatedModel that doesn't do any replication? Just load the version as a read-only model instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a unified api: A user may or may not have permission to modify the document – we want to use the same api in both cases.

Also: We typically want to modify only one model and load one or more additional models as readonly libraries.

This felt like the easiest implementation – we can also consider generalizing ReplicatedModel though (ReplicatedModel and ReadonlyModel would implement an interface called …Model then?)…

awaitCancellation()
}

override suspend fun push(version: CLVersion): CLVersion {
throw UnsupportedOperationException("Read-only model")
}
}

private fun IVersion.upcast(): CLVersion = this as CLVersion
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,14 @@ interface ClientJS {
@JsExport
data class ReplicatedModelParameters(
val repositoryId: String,
val branchId: String,
val branchId: String? = null,
val idScheme: IdSchemeJS,
)
val versionHash: String? = null,
) {
init {
require((branchId != null) xor (versionHash != null)) { "Exactly one of branchId or versionHash must be provided" }
}
}

internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS {

Expand Down Expand Up @@ -289,13 +294,20 @@ internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS {
return GlobalScope.promise {
val models = parameters.map { parameters ->
val modelClient = modelClient
val branchReference = RepositoryId(parameters.repositoryId).getBranchReference(parameters.branchId)
val repositoryId = RepositoryId(parameters.repositoryId)
val branchReference = parameters.branchId?.let { repositoryId.getBranchReference(it) }
val idGenerator: (TreeId) -> INodeIdGenerator<INodeReference> = when (parameters.idScheme) {
IdSchemeJS.READONLY -> { treeId -> DummyIdGenerator() }
IdSchemeJS.MODELIX -> { treeId -> ModelixIdGenerator(modelClient.getIdGenerator(), treeId) }
IdSchemeJS.MPS -> { treeId -> MPSIdGenerator(modelClient.getIdGenerator(), treeId) }
}
modelClient.getReplicatedModel(branchReference, idGenerator).also { it.start() }
ReplicatedModel(
client = modelClient,
branchRefOrNull = branchReference,
idGenerator = idGenerator,
versionHash = parameters.versionHash,
repositoryId = repositoryId,
).also { it.start() }
}
ReplicatedModelJSImpl(models)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.modelix.model.client2

import kotlin.test.Test
import kotlin.test.assertFailsWith

class ReplicatedModelHashTest {

@Test
fun ReplicatedModelParameters_validation() {
// Valid: branchId
// branchId is positional 2nd arg.
ReplicatedModelParameters("repo", "branch", IdSchemeJS.MODELIX)

// Valid: versionHash
// branchId must be null.
ReplicatedModelParameters(
repositoryId = "repo",
branchId = null,
idScheme = IdSchemeJS.MODELIX,
versionHash = "hash",
)

// Invalid: both branchId and versionHash
assertFailsWith<IllegalArgumentException> {
ReplicatedModelParameters(
repositoryId = "repo",
branchId = "branch",
idScheme = IdSchemeJS.MODELIX,
versionHash = "hash",
)
}

// Invalid: neither
assertFailsWith<IllegalArgumentException> {
ReplicatedModelParameters(
repositoryId = "repo",
branchId = null,
idScheme = IdSchemeJS.MODELIX,
)
}
}
}
2 changes: 1 addition & 1 deletion vue-model-api/src/useReplicatedModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ test("test wrapper backwards compatibility", (done) => {
// Mock implementation that returns a dummy object with a branch
const branchId = parameters[0].branchId;
const rootNode = loadModelsFromJson([JSON.stringify({ root: {} })]);
rootNode.setPropertyValue(toRoleJS("branchId"), branchId);
rootNode.setPropertyValue(toRoleJS("branchId"), branchId ?? undefined);

const branch = {
rootNode,
Expand Down
2 changes: 1 addition & 1 deletion vue-model-api/src/useReplicatedModels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ test("test branch connects", (done) => {
const branchId = parameters[0].branchId;
return Promise.resolve(
new SuccessfulReplicatedModelJS(
branchId,
branchId!,
) as unknown as ReplicatedModelJS,
);
}
Expand Down
Loading