From cf083a6ec107f12e65a26d7123994327ab2f2f20 Mon Sep 17 00:00:00 2001 From: Isuru Rajapakse Date: Sat, 21 Sep 2024 10:42:00 +1000 Subject: [PATCH] Add transactions to file writes and restore files to previous state when transactions fail (#100) * Add missing documentation and labelled params * Update file codec to write to a temp file and move once completed * Add test cases for transactional encodes * Update kstore api * Move storeOf to kstore * Add test case for close * Fix VersionedCodec --- kstore-file/api/android/kstore-file.api | 2 +- kstore-file/api/desktop/kstore-file.api | 2 +- .../io/github/xxfast/kstore/file/FileCodec.kt | 34 +++++++++++--- .../io/github/xxfast/kstore/file/KStore.kt | 24 +--------- .../kstore/file/extensions/KVersionedStore.kt | 2 +- .../xxfast/kstore/file/FileCodecTests.kt | 45 ++++++++++++------- .../github/xxfast/kstore/file/TestModels.kt | 34 ++++++++++++-- kstore/api/android/kstore.api | 3 +- kstore/api/desktop/kstore.api | 3 +- .../kotlin/io/github/xxfast/kstore/Codec.kt | 9 ++++ .../kotlin/io/github/xxfast/kstore/KStore.kt | 24 +++++++++- .../io/github/xxfast/kstore/KStoreTests.kt | 11 ++++- 12 files changed, 138 insertions(+), 55 deletions(-) diff --git a/kstore-file/api/android/kstore-file.api b/kstore-file/api/android/kstore-file.api index db52c37..d0c86ed 100644 --- a/kstore-file/api/android/kstore-file.api +++ b/kstore-file/api/android/kstore-file.api @@ -1,5 +1,5 @@ public final class io/github/xxfast/kstore/file/FileCodec : io/github/xxfast/kstore/Codec { - public fun (Lkotlinx/io/files/Path;Lkotlinx/serialization/json/Json;Lkotlinx/serialization/KSerializer;)V + public fun (Lkotlinx/io/files/Path;Lkotlinx/io/files/Path;Lkotlinx/serialization/json/Json;Lkotlinx/serialization/KSerializer;)V public fun decode (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun encode (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/kstore-file/api/desktop/kstore-file.api b/kstore-file/api/desktop/kstore-file.api index db52c37..d0c86ed 100644 --- a/kstore-file/api/desktop/kstore-file.api +++ b/kstore-file/api/desktop/kstore-file.api @@ -1,5 +1,5 @@ public final class io/github/xxfast/kstore/file/FileCodec : io/github/xxfast/kstore/Codec { - public fun (Lkotlinx/io/files/Path;Lkotlinx/serialization/json/Json;Lkotlinx/serialization/KSerializer;)V + public fun (Lkotlinx/io/files/Path;Lkotlinx/io/files/Path;Lkotlinx/serialization/json/Json;Lkotlinx/serialization/KSerializer;)V public fun decode (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun encode (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/FileCodec.kt b/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/FileCodec.kt index 38a4268..6e4631a 100644 --- a/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/FileCodec.kt +++ b/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/FileCodec.kt @@ -11,10 +11,11 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import kotlinx.serialization.json.io.decodeFromSource -import kotlinx.serialization.json.io.encodeToSink import kotlinx.serialization.serializer +import kotlinx.serialization.json.io.decodeFromSource as decode +import kotlinx.serialization.json.io.encodeToSink as encode + /** * Creates a store with [FileCodec] with json serializer * @param file path to the file that is managed by this store @@ -23,30 +24,51 @@ import kotlinx.serialization.serializer */ public inline fun FileCodec( file: Path, + tempFile: Path = Path("${file.name}.temp"), json: Json = DefaultJson, ): FileCodec = FileCodec( file = file, + tempFile = tempFile, json = json, serializer = json.serializersModule.serializer(), ) public class FileCodec( private val file: Path, + private val tempFile: Path, private val json: Json, private val serializer: KSerializer, ) : Codec { - + /** + * Decodes the file to a value. + * If the file does not exist, null is returned. + * @return optional value that is decoded + */ @OptIn(ExperimentalSerializationApi::class) override suspend fun decode(): T? = try { - SystemFileSystem.source(file).buffered().use { json.decodeFromSource(serializer, it) } + SystemFileSystem.source(file).buffered().use { json.decode(serializer, it) } } catch (e: FileNotFoundException) { null } + /** + * Encodes the given value to the file. + * If the value is null, the file is deleted. + * If the encoding fails, the temp file is deleted. + * If the encoding succeeds, the temp file is atomically moved to the target file - completing the transaction. + * @param value optional value to encode + */ @OptIn(ExperimentalSerializationApi::class) override suspend fun encode(value: T?) { - if (value != null) SystemFileSystem.sink(file).buffered().use { json.encodeToSink(serializer, value, it) } - else SystemFileSystem.delete(file) + try { + if (value != null) SystemFileSystem.sink(tempFile).buffered().use { json.encode(serializer, value, it) } + else SystemFileSystem.delete(tempFile) + } catch (e: Throwable) { + SystemFileSystem.delete(tempFile) + throw e + } + + SystemFileSystem.atomicMove(source = tempFile, destination = file) } } diff --git a/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/KStore.kt b/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/KStore.kt index 030b6fd..4b97a94 100644 --- a/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/KStore.kt +++ b/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/KStore.kt @@ -1,8 +1,8 @@ package io.github.xxfast.kstore.file -import io.github.xxfast.kstore.Codec import io.github.xxfast.kstore.DefaultJson import io.github.xxfast.kstore.KStore +import io.github.xxfast.kstore.storeOf import kotlinx.io.files.Path import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -23,27 +23,7 @@ public inline fun storeOf( enableCache: Boolean = true, json: Json = DefaultJson, ): KStore = storeOf( - codec = FileCodec(file, json), + codec = FileCodec(file = file, json = json), default = default, enableCache = enableCache, ) - -/** - * Creates a store with a given codec - * - * @param default returns this value if the file is not found. defaults to null - * @param enableCache maintain a cache. If set to false, it always reads from disk - * @param codec codec to be used. - * - * @return store that contains a value of type [T] - */ -public inline fun storeOf( - codec: Codec, - default: T? = null, - enableCache: Boolean = true, -): KStore = KStore( - default = default, - enableCache = enableCache, - codec = codec -) - diff --git a/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/extensions/KVersionedStore.kt b/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/extensions/KVersionedStore.kt index 8066e09..db3996d 100644 --- a/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/extensions/KVersionedStore.kt +++ b/kstore-file/src/commonMain/kotlin/io/github/xxfast/kstore/file/extensions/KVersionedStore.kt @@ -3,7 +3,7 @@ package io.github.xxfast.kstore.file.extensions import io.github.xxfast.kstore.Codec import io.github.xxfast.kstore.DefaultJson import io.github.xxfast.kstore.KStore -import io.github.xxfast.kstore.file.storeOf +import io.github.xxfast.kstore.storeOf import kotlinx.io.buffered import kotlinx.io.files.FileNotFoundException import kotlinx.io.files.Path diff --git a/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/FileCodecTests.kt b/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/FileCodecTests.kt index 152dc37..6aebe06 100644 --- a/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/FileCodecTests.kt +++ b/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/FileCodecTests.kt @@ -15,19 +15,20 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -class FileCodecTests { - private val file = Path(FILE_PATH) - private val codec: FileCodec = FileCodec( - file = file, - json = DefaultJson, - ) +class FileCodecTests { + private val codec: FileCodec> = FileCodec(file = Path(FILE_PATH)) @OptIn(ExperimentalSerializationApi::class) - private var stored: Cat? - get() = SystemFileSystem.source(file).buffered().use { DefaultJson.decodeFromSource(it) } + private var stored: List? + get() = SystemFileSystem.source(Path(FILE_PATH)) + .buffered() + .use { DefaultJson.decodeFromSource(it) } + set(value) { - SystemFileSystem.sink(file).buffered().use { DefaultJson.encodeToSink(value, it) } + SystemFileSystem.sink(Path(FILE_PATH)) + .buffered() + .use { DefaultJson.encodeToSink(value, it) } } @AfterTest @@ -37,23 +38,35 @@ class FileCodecTests { @Test fun testEncode() = runTest { - codec.encode(MYLO) - val expect: Cat = MYLO - val actual: Cat? = stored + codec.encode(listOf(MYLO)) + val expect: List = listOf(MYLO) + val actual: List? = stored assertEquals(expect, actual) } @Test fun testDecode() = runTest { - stored = OREO - val expect: Cat = OREO - val actual: Cat? = codec.decode() + stored = listOf(OREO) + val expect: List = listOf(OREO) + val actual: List? = codec.decode() assertEquals(expect, actual) } + @Test + fun testTransactionalEncode() = runTest { + codec.encode(listOf(MYLO)) + + // Encoder will fail half way through + assertFailsWith { codec.encode(listOf(MYLO, KAT)) } + + // The original file should not be touched + val actual: List? = codec.decode() + assertEquals(listOf(MYLO), actual) + } + @Test fun testDecodeMalformedFile() = runTest { - SystemFileSystem.sink(file).buffered().use { it.writeString("💩") } + SystemFileSystem.sink(Path(FILE_PATH)).buffered().use { it.writeString("💩") } assertFailsWith { codec.decode() } } } diff --git a/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/TestModels.kt b/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/TestModels.kt index 18ba679..4fca357 100644 --- a/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/TestModels.kt +++ b/kstore-file/src/commonTest/kotlin/io/github/xxfast/kstore/file/TestModels.kt @@ -1,11 +1,18 @@ package io.github.xxfast.kstore.file +import io.github.xxfast.kstore.file.RobotCat.Id +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder @Serializable -sealed class Pet { - abstract val name: String - abstract val age: Int +sealed interface Pet { + val name: String + val age: Number } @Serializable @@ -13,9 +20,28 @@ data class Cat( override val name: String, override val age: Int, val lives: Int = 9, -) : Pet() +) : Pet + +@Serializable +data class RobotCat( + override val name: String, + override val age: Int, + val id: Id +): Pet { + + // This field helps to simulate encoder to fail half way through + @Serializable(with = Id.Serializer::class) + class Id(val value: Long) { + companion object Serializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Id", PrimitiveKind.LONG) + override fun deserialize(decoder: Decoder): Id { TODO("Not yet implemented") } + override fun serialize(encoder: Encoder, value: Id) { TODO("Not yet implemented") } + } + } +} internal val MYLO = Cat(name = "Mylo", age = 1) internal val OREO = Cat(name = "Oreo", age = 1) +internal val KAT = RobotCat(name = "Kat", age = 12, id = Id(123L)) const val FILE_PATH = "test.json" diff --git a/kstore/api/android/kstore.api b/kstore/api/android/kstore.api index ade06cc..e99d219 100644 --- a/kstore/api/android/kstore.api +++ b/kstore/api/android/kstore.api @@ -7,9 +7,10 @@ public final class io/github/xxfast/kstore/JsonKt { public static final fun getDefaultJson ()Lkotlinx/serialization/json/Json; } -public final class io/github/xxfast/kstore/KStore { +public final class io/github/xxfast/kstore/KStore : java/lang/AutoCloseable { public fun (Ljava/lang/Object;ZLio/github/xxfast/kstore/Codec;)V public synthetic fun (Ljava/lang/Object;ZLio/github/xxfast/kstore/Codec;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V public final fun delete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun get (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getUpdates ()Lkotlinx/coroutines/flow/Flow; diff --git a/kstore/api/desktop/kstore.api b/kstore/api/desktop/kstore.api index ade06cc..e99d219 100644 --- a/kstore/api/desktop/kstore.api +++ b/kstore/api/desktop/kstore.api @@ -7,9 +7,10 @@ public final class io/github/xxfast/kstore/JsonKt { public static final fun getDefaultJson ()Lkotlinx/serialization/json/Json; } -public final class io/github/xxfast/kstore/KStore { +public final class io/github/xxfast/kstore/KStore : java/lang/AutoCloseable { public fun (Ljava/lang/Object;ZLio/github/xxfast/kstore/Codec;)V public synthetic fun (Ljava/lang/Object;ZLio/github/xxfast/kstore/Codec;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V public final fun delete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun get (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getUpdates ()Lkotlinx/coroutines/flow/Flow; diff --git a/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/Codec.kt b/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/Codec.kt index 17fd202..1a987bb 100644 --- a/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/Codec.kt +++ b/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/Codec.kt @@ -6,6 +6,15 @@ import kotlinx.serialization.Serializable * Encoding and decoding behavior that is used by the store */ public interface Codec { + /** + * Tells the store how to encode an given value + * @param value optional value to encode + */ public suspend fun encode(value: T?) + + /** + * Tells the store how to decode an given value + * @return optional value that is decoded + */ public suspend fun decode(): T? } diff --git a/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/KStore.kt b/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/KStore.kt index 5460f2e..05bafe2 100644 --- a/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/KStore.kt +++ b/kstore/src/commonMain/kotlin/io/github/xxfast/kstore/KStore.kt @@ -9,6 +9,24 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable +/** + * Creates a store with a given codec + * + * @param default returns this value if the file is not found. defaults to null + * @param enableCache maintain a cache. If set to false, it always reads from disk + * @param codec codec to be used. + * + * @return store that contains a value of type [T] + */ +public inline fun storeOf( + codec: Codec, + default: T? = null, + enableCache: Boolean = true, +): KStore = KStore( + default = default, + enableCache = enableCache, + codec = codec +) /** * Creates a store with a custom encoder and a decoder @@ -21,7 +39,7 @@ public class KStore( private val default: T? = null, private val enableCache: Boolean = true, private val codec: Codec, -) { +) : AutoCloseable { private val lock: Mutex = Mutex() internal val cache: MutableStateFlow = MutableStateFlow(default) @@ -88,4 +106,8 @@ public class KStore( set(default) cache.emit(default) } + + override fun close() { + if (lock.isLocked) lock.unlock() + } } diff --git a/kstore/src/commonTest/kotlin/io/github/xxfast/kstore/KStoreTests.kt b/kstore/src/commonTest/kotlin/io/github/xxfast/kstore/KStoreTests.kt index cb59850..32c2f7c 100644 --- a/kstore/src/commonTest/kotlin/io/github/xxfast/kstore/KStoreTests.kt +++ b/kstore/src/commonTest/kotlin/io/github/xxfast/kstore/KStoreTests.kt @@ -12,7 +12,7 @@ import kotlin.test.assertEquals import kotlin.test.assertSame class KStoreTests { - private val store: KStore = KStore(codec = TestCodec()) + private val store: KStore = storeOf(codec = TestCodec()) @AfterTest fun cleanup() { @@ -185,4 +185,13 @@ class KStoreTests { val expect: Pet = MYLO.copy(age = MYLO.age + 1) assertEquals(actual, expect) } + + @Test + fun testClose() = runTest { + store.close() + store.set(MYLO) + val expect: Pet = MYLO + val actual: Pet? = store.get() + assertEquals(expect, actual) + } }