From abc165c45e0a2ccd00db1ffd975048e592dc058f Mon Sep 17 00:00:00 2001 From: CharlieTap Date: Sat, 26 Nov 2022 13:58:44 +0000 Subject: [PATCH] added load and store functions for persistence with tests --- build.gradle.kts | 4 +- gradle/libs.versions.toml | 3 + .../kotlin/com/tap/hlc/HybridLogicalClock.kt | 48 ++++++++++- .../kotlin/com/tap/hlc/HLCPersistTest.kt | 85 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/commonTest/kotlin/com/tap/hlc/HLCPersistTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 4d4c934..6a13d3c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tap.hlc" -version = "1.0.1" +version = "1.1.0" kotlin { targets { @@ -15,6 +15,7 @@ kotlin { sourceSets { val commonMain by getting { dependencies { + api(libs.okio.core) api(libs.kotlinx.datetime) api(libs.result) api(libs.uuid) @@ -24,6 +25,7 @@ kotlin { val commonTest by getting { dependencies { implementation(kotlin("test")) + implementation(libs.okio.fakefilesystem) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2fdd5d4..e103a4c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ kotlinx-coroutines = "1.6.4" kotlinx-datetime = "0.4.0" kotlinx-serialization = "1.4.1" +okio="3.1.0" result = "1.1.16" uuid = "0.5.0" @@ -24,6 +25,8 @@ kotlinter = { id = "org.jmailen.kotlinter", version.ref = "kotlinter" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime"} kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization"} +okio-core = { module = "com.squareup.okio:okio", version.ref = "okio"} +okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio"} result = { module = "com.michael-bull.kotlin-result:kotlin-result", version.ref = "result" } uuid = { module = "com.benasher44:uuid", version.ref = "uuid"} diff --git a/src/commonMain/kotlin/com/tap/hlc/HybridLogicalClock.kt b/src/commonMain/kotlin/com/tap/hlc/HybridLogicalClock.kt index 86deb59..0ea6040 100644 --- a/src/commonMain/kotlin/com/tap/hlc/HybridLogicalClock.kt +++ b/src/commonMain/kotlin/com/tap/hlc/HybridLogicalClock.kt @@ -4,10 +4,13 @@ import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import com.github.michaelbull.result.flatMap +import com.github.michaelbull.result.getOr import kotlinx.datetime.Clock import kotlin.math.abs import kotlin.math.max import kotlin.math.pow +import okio.FileSystem +import okio.Path /** * Implementation of a HLC [1][2] @@ -26,7 +29,11 @@ data class HybridLogicalClock( companion object { - // Call this every time a new event is generated on the node, set the local clock and the events timestamp equal to the result + private const val CLOCK_FILE = "clock.hlc" + + /** + * This should be called every time a new event is generated locally, the result becomes the events timestamp and the new local time + */ fun localTick( local: HybridLogicalClock, wallClockTime: Timestamp = Timestamp.now(Clock.System), @@ -39,7 +46,9 @@ data class HybridLogicalClock( } } - // Call this on all events from external nodes to create a new local hlc which factors in the remote event + /** + * This should be called every time a new event is received from a remote node, the result becomes the new local time + */ fun remoteTock( local: HybridLogicalClock, remote: HybridLogicalClock, @@ -100,6 +109,41 @@ data class HybridLogicalClock( return Ok(HybridLogicalClock(timestamp, node, counter)) } + + /** + * Persists the clock to a disk file at the specified directory path + * + * This call is blocking + * + * Usage: + * val directory = "/Users/alice".toPath() + * HybridLogicalClock.store(hlc, path) + */ + fun store(hlc: HybridLogicalClock, directory: Path, fileSystem: FileSystem = FileSystem.SYSTEM, fileName : String = CLOCK_FILE) { + fileSystem.createDirectories(directory) + val filepath = directory / fileName + fileSystem.write(filepath) { + writeUtf8(hlc.toString()) + } + } + + /** + * Attempts to load a clock from a disk file at the specified directory path + * + * This call is blocking and will return null if no file is found + * + * Usage: + * val directory = "/Users/alice".toPath() + * val nullableClock = HybridLogicalClock.load(path) + */ + fun load(directory: Path, fileSystem: FileSystem = FileSystem.SYSTEM, fileName: String = CLOCK_FILE) : HybridLogicalClock? { + val filepath = directory / fileName + if(!fileSystem.exists(filepath)) { + return null + } + val encoded = fileSystem.read(filepath) { readUtf8() } + return decodeFromString(encoded).getOr(null) + } } override fun compareTo(other: HybridLogicalClock): Int { diff --git a/src/commonTest/kotlin/com/tap/hlc/HLCPersistTest.kt b/src/commonTest/kotlin/com/tap/hlc/HLCPersistTest.kt new file mode 100644 index 0000000..ef209b3 --- /dev/null +++ b/src/commonTest/kotlin/com/tap/hlc/HLCPersistTest.kt @@ -0,0 +1,85 @@ +package com.tap.hlc + +import com.benasher44.uuid.uuid4 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem + +class HLCPersistTest { + + @Test + fun `can store the hlc into a file at a given path`() { + + val fileSystem = FakeFileSystem() + + val epochMillis = 943920000000L + val counter = 15 + val node = uuid4() + + val clock = HybridLogicalClock(Timestamp(epochMillis), NodeID.mint(node), counter) + val path = "/Users/alice".toPath() + val filename = "test.hlc" + + HybridLogicalClock.store(clock, path, fileSystem, filename) + + val expectedEncoded = "${epochMillis.toString().padStart(15, '0')}:${counter.toString(36).padStart(5, '0')}:${node.toString().replace("-", "").takeLast(16)}" + val result = fileSystem.read(path / filename) { + readUtf8() + } + + assertEquals(expectedEncoded, result) + fileSystem.checkNoOpenFiles() + } + + @Test + fun `can load a hlc from a given path`() { + + val fileSystem = FakeFileSystem() + val path = "/Users/alice".toPath() + fileSystem.createDirectories(path) + val filename = "test.hlc" + + val epochMillis = 943920000000L + val counter = 15 + val node = uuid4().toString().replace("-", "").takeLast(16) + + val encoded = "${epochMillis.toString().padStart(15, '0')}:${counter.toString(36).padStart(5, '0')}:$node" + + fileSystem.write(path / filename) { + writeUtf8(encoded) + } + + val result = HybridLogicalClock.load(path, fileSystem, filename) + + assertNotNull(result) + assertEquals(result.timestamp.epochMillis, epochMillis) + assertEquals(result.counter, counter) + assertEquals(result.node.identifier, node) + fileSystem.checkNoOpenFiles() + } + + @Test + fun `can store and load a hlc to and from a given path`() { + + val fileSystem = FakeFileSystem() + val path = "/Users/alice".toPath() + fileSystem.createDirectories(path) + val filename = "test.hlc" + + val epochMillis = 943920000000L + val counter = 15 + val node = uuid4() + + val clock = HybridLogicalClock(Timestamp(epochMillis), NodeID.mint(node), counter) + HybridLogicalClock.store(clock, path, fileSystem, filename) + val result = HybridLogicalClock.load(path, fileSystem, filename) + + assertNotNull(result) + assertEquals(result.timestamp.epochMillis, epochMillis) + assertEquals(result.counter, counter) + assertEquals(result.node.identifier, NodeID.mint(node).identifier) + fileSystem.checkNoOpenFiles() + } +}