Skip to content

Commit

Permalink
Add transactions to file writes and restore files to previous state w…
Browse files Browse the repository at this point in the history
…hen 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
  • Loading branch information
xxfast committed Sep 21, 2024
1 parent b30cb48 commit cf083a6
Show file tree
Hide file tree
Showing 12 changed files with 138 additions and 55 deletions.
2 changes: 1 addition & 1 deletion kstore-file/api/android/kstore-file.api
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
public final class io/github/xxfast/kstore/file/FileCodec : io/github/xxfast/kstore/Codec {
public fun <init> (Lkotlinx/io/files/Path;Lkotlinx/serialization/json/Json;Lkotlinx/serialization/KSerializer;)V
public fun <init> (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;
}
Expand Down
2 changes: 1 addition & 1 deletion kstore-file/api/desktop/kstore-file.api
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
public final class io/github/xxfast/kstore/file/FileCodec : io/github/xxfast/kstore/Codec {
public fun <init> (Lkotlinx/io/files/Path;Lkotlinx/serialization/json/Json;Lkotlinx/serialization/KSerializer;)V
public fun <init> (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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,30 +24,51 @@ import kotlinx.serialization.serializer
*/
public inline fun <reified T : @Serializable Any> FileCodec(
file: Path,
tempFile: Path = Path("${file.name}.temp"),
json: Json = DefaultJson,
): FileCodec<T> = FileCodec(
file = file,
tempFile = tempFile,
json = json,
serializer = json.serializersModule.serializer(),
)

public class FileCodec<T : @Serializable Any>(
private val file: Path,
private val tempFile: Path,
private val json: Json,
private val serializer: KSerializer<T>,
) : Codec<T> {

/**
* 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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,27 +23,7 @@ public inline fun <reified T : @Serializable Any> storeOf(
enableCache: Boolean = true,
json: Json = DefaultJson,
): KStore<T> = 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 <reified T : @Serializable Any> storeOf(
codec: Codec<T>,
default: T? = null,
enableCache: Boolean = true,
): KStore<T> = KStore(
default = default,
enableCache = enableCache,
codec = codec
)

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cat> = FileCodec(
file = file,
json = DefaultJson,
)
class FileCodecTests {
private val codec: FileCodec<List<Pet>> = 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<Pet>?
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
Expand All @@ -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<Pet> = listOf(MYLO)
val actual: List<Pet>? = 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<Pet> = listOf(OREO)
val actual: List<Pet>? = codec.decode()
assertEquals(expect, actual)
}

@Test
fun testTransactionalEncode() = runTest {
codec.encode(listOf(MYLO))

// Encoder will fail half way through
assertFailsWith<NotImplementedError> { codec.encode(listOf(MYLO, KAT)) }

// The original file should not be touched
val actual: List<Pet>? = 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<SerializationException> { codec.decode() }
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,47 @@
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
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<Id> {
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"
3 changes: 2 additions & 1 deletion kstore/api/android/kstore.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Ljava/lang/Object;ZLio/github/xxfast/kstore/Codec;)V
public synthetic fun <init> (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;
Expand Down
3 changes: 2 additions & 1 deletion kstore/api/desktop/kstore.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Ljava/lang/Object;ZLio/github/xxfast/kstore/Codec;)V
public synthetic fun <init> (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;
Expand Down
9 changes: 9 additions & 0 deletions kstore/src/commonMain/kotlin/io/github/xxfast/kstore/Codec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import kotlinx.serialization.Serializable
* Encoding and decoding behavior that is used by the store
*/
public interface Codec<T: @Serializable Any> {
/**
* 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?
}
24 changes: 23 additions & 1 deletion kstore/src/commonMain/kotlin/io/github/xxfast/kstore/KStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reified T : @Serializable Any> storeOf(
codec: Codec<T>,
default: T? = null,
enableCache: Boolean = true,
): KStore<T> = KStore(
default = default,
enableCache = enableCache,
codec = codec
)

/**
* Creates a store with a custom encoder and a decoder
Expand All @@ -21,7 +39,7 @@ public class KStore<T : @Serializable Any>(
private val default: T? = null,
private val enableCache: Boolean = true,
private val codec: Codec<T>,
) {
) : AutoCloseable {
private val lock: Mutex = Mutex()
internal val cache: MutableStateFlow<T?> = MutableStateFlow(default)

Expand Down Expand Up @@ -88,4 +106,8 @@ public class KStore<T : @Serializable Any>(
set(default)
cache.emit(default)
}

override fun close() {
if (lock.isLocked) lock.unlock()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertSame

class KStoreTests {
private val store: KStore<Cat> = KStore(codec = TestCodec())
private val store: KStore<Cat> = storeOf(codec = TestCodec())

@AfterTest
fun cleanup() {
Expand Down Expand Up @@ -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)
}
}

0 comments on commit cf083a6

Please sign in to comment.