From 33a571bd340910fa0ba5683f43ae11a90188dde5 Mon Sep 17 00:00:00 2001 From: Cedrick Cooke Date: Thu, 1 Feb 2024 11:02:52 -0800 Subject: [PATCH] `AtomicMap` implementation (#337) --- collections/api/collections.api | 47 +++++++++ collections/build.gradle.kts | 8 +- .../src/commonMain/kotlin/AtomicMap.kt | 95 +++++++++++++++++++ .../src/commonMain/kotlin/SynchronizedMap.kt | 12 +++ .../src/commonTest/kotlin/AtomicMapTests.kt | 44 +++++++++ gradle/libs.versions.toml | 1 + 6 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 collections/src/commonMain/kotlin/AtomicMap.kt create mode 100644 collections/src/commonTest/kotlin/AtomicMapTests.kt diff --git a/collections/api/collections.api b/collections/api/collections.api index a660400b..e2b1c7a6 100644 --- a/collections/api/collections.api +++ b/collections/api/collections.api @@ -1,3 +1,50 @@ +public final class com/juul/tuulbox/collections/AtomicMap : java/util/Map, kotlin/jvm/internal/markers/KMappedMarker { + public fun (Lkotlinx/collections/immutable/PersistentMap;)V + public fun clear ()V + public fun compute (Ljava/lang/Object;Ljava/util/function/BiFunction;)Ljava/lang/Object; + public fun computeIfAbsent (Ljava/lang/Object;Ljava/util/function/Function;)Ljava/lang/Object; + public fun computeIfPresent (Ljava/lang/Object;Ljava/util/function/BiFunction;)Ljava/lang/Object; + public fun containsKey (Ljava/lang/Object;)Z + public fun containsValue (Ljava/lang/Object;)Z + public synthetic fun entrySet ()Ljava/util/Set; + public final fun entrySet ()Lkotlinx/collections/immutable/ImmutableSet; + public fun equals (Ljava/lang/Object;)Z + public fun get (Ljava/lang/Object;)Ljava/lang/Object; + public fun getEntries ()Lkotlinx/collections/immutable/ImmutableSet; + public fun getKeys ()Lkotlinx/collections/immutable/ImmutableSet; + public fun getSize ()I + public final fun getSnapshot ()Lkotlinx/collections/immutable/ImmutableMap; + public final fun getSnapshots ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getState ()Lkotlinx/coroutines/flow/MutableStateFlow; + public fun getValues ()Lkotlinx/collections/immutable/ImmutableCollection; + public fun hashCode ()I + public fun isEmpty ()Z + public synthetic fun keySet ()Ljava/util/Set; + public final fun keySet ()Lkotlinx/collections/immutable/ImmutableSet; + public fun merge (Ljava/lang/Object;Ljava/lang/Object;Ljava/util/function/BiFunction;)Ljava/lang/Object; + public final fun mutate (Lkotlin/jvm/functions/Function1;)V + public final fun mutateAndSnapshot (Lkotlin/jvm/functions/Function1;)Lkotlinx/collections/immutable/ImmutableMap; + public fun put (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun putAll (Ljava/util/Map;)V + public fun putIfAbsent (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun remove (Ljava/lang/Object;)Ljava/lang/Object; + public fun remove (Ljava/lang/Object;Ljava/lang/Object;)Z + public fun replace (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun replace (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z + public fun replaceAll (Ljava/util/function/BiFunction;)V + public final fun size ()I + public final fun snapshotAndMutate (Lkotlin/jvm/functions/Function1;)Lkotlinx/collections/immutable/ImmutableMap; + public fun toString ()Ljava/lang/String; + public synthetic fun values ()Ljava/util/Collection; + public final fun values ()Lkotlinx/collections/immutable/ImmutableCollection; +} + +public final class com/juul/tuulbox/collections/AtomicMapKt { + public static final fun atomicHashMapOf ([Lkotlin/Pair;)Lcom/juul/tuulbox/collections/AtomicMap; + public static final fun atomicMapOf ([Lkotlin/Pair;)Lcom/juul/tuulbox/collections/AtomicMap; + public static final fun getOrPut (Lcom/juul/tuulbox/collections/AtomicMap;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; +} + public final class com/juul/tuulbox/collections/SynchronizedMap : java/util/Map, kotlin/jvm/internal/markers/KMutableMap { public fun ()V public fun (I)V diff --git a/collections/build.gradle.kts b/collections/build.gradle.kts index fd863159..7cf452e3 100644 --- a/collections/build.gradle.kts +++ b/collections/build.gradle.kts @@ -22,10 +22,16 @@ kotlin { iosSimulatorArm64() sourceSets { - val commonMain by getting { } + val commonMain by getting { + dependencies { + api(libs.kotlinx.collections.immutable) + api(libs.kotlinx.coroutines.core) + } + } val commonTest by getting { dependencies { + implementation(libs.kotlinx.coroutines.test) implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) } diff --git a/collections/src/commonMain/kotlin/AtomicMap.kt b/collections/src/commonMain/kotlin/AtomicMap.kt new file mode 100644 index 00000000..0ae76055 --- /dev/null +++ b/collections/src/commonMain/kotlin/AtomicMap.kt @@ -0,0 +1,95 @@ +package com.juul.tuulbox.collections + +import kotlinx.collections.immutable.ImmutableCollection +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet + +/** Returns an [AtomicMap] that guarantees preservation of iteration order, but may be slower. */ +public fun atomicMapOf( + vararg pairs: Pair, +): AtomicMap = AtomicMap(persistentMapOf(*pairs)) + +/** Returns an [AtomicMap] that does not guarantee preservation of iteration order, but may be faster. */ +public fun atomicHashMapOf( + vararg pairs: Pair, +): AtomicMap = AtomicMap(persistentHashMapOf(*pairs)) + +/** + * A [Map] that allows for thread safe, atomic mutation. Returned collections such as [entries] and + * [iterator] reference a [snapshot] of when they were accessed, and are not mutated when the map is. + * + * Although mutable, this class intentionally does not implement [MutableMap]. Mutation must use + * designated mutator functions ([mutate], [snapshotAndMutate], [mutateAndSnapshot]). + */ +public class AtomicMap private constructor( + @PublishedApi + internal val state: MutableStateFlow>, +) : Map { + + /** Construct an [AtomicMap] with [initial] mappings. */ + public constructor(initial: PersistentMap) : this(MutableStateFlow(initial)) + + /** Returns this map as a [StateFlow]. Each mutation will cause a new emission on this flow. */ + public val snapshots: StateFlow> = state.asStateFlow() + + /** + * Returns the current value of this map as an [immutable][ImmutableMap] snapshot. + * + * This operation is non-copying and efficient. + */ + public val snapshot: ImmutableMap + get() = snapshots.value + + override val size: Int + get() = snapshot.size + + override val entries: ImmutableSet> + get() = snapshot.entries + + override val keys: ImmutableSet + get() = snapshot.keys + + override val values: ImmutableCollection + get() = snapshot.values + + override fun containsKey(key: K): Boolean = snapshot.containsKey(key) + + override fun containsValue(value: V): Boolean = snapshot.containsValue(value) + + override fun get(key: K): V? = snapshot[key] + + override fun isEmpty(): Boolean = snapshot.isEmpty() + + override fun equals(other: Any?): Boolean = snapshot == other + + override fun hashCode(): Int = snapshot.hashCode() + + override fun toString(): String = snapshot.toString() + + /** Mutates this map atomically. [mutator] can be evaluated multiple times if a concurrent edit occurs. */ + public inline fun mutate(mutator: MutableMap.() -> Unit) { + state.update { it.mutate(mutator) } + } + + /** Mutates this map atomically and returns the previous [snapshot]. [mutator] can be evaluated multiple times if a concurrent edit occurs. */ + public inline fun snapshotAndMutate(mutator: MutableMap.() -> Unit): ImmutableMap = + state.getAndUpdate { it.mutate(mutator) } + + /** Mutates this map atomically and returns the new [snapshot]. [mutator] can be evaluated multiple times if a concurrent edit occurs. */ + public inline fun mutateAndSnapshot(mutator: MutableMap.() -> Unit): ImmutableMap = + state.updateAndGet { it.mutate(mutator) } +} + +/** Atomic version of [MutableMap.getOrPut]. [defaultValue] can be evaluated multiple times if a concurrent edit occurs. */ +public inline fun AtomicMap.getOrPut(key: K, defaultValue: () -> V): V = + mutateAndSnapshot { getOrPut(key, defaultValue) }.getValue(key) diff --git a/collections/src/commonMain/kotlin/SynchronizedMap.kt b/collections/src/commonMain/kotlin/SynchronizedMap.kt index 2184fd16..4f597f72 100644 --- a/collections/src/commonMain/kotlin/SynchronizedMap.kt +++ b/collections/src/commonMain/kotlin/SynchronizedMap.kt @@ -5,14 +5,26 @@ import kotlinx.atomicfu.locks.reentrantLock import kotlinx.atomicfu.locks.withLock /** Returns an empty new [SynchronizedMap]. */ +@Deprecated( + "Prefer using atomicMapOf instead. SynchronizedMap does not play nicely with coroutines.", + ReplaceWith("atomicMapOf", "com.juul.tuulbox.collections.atomicMapOf"), +) public fun synchronizedMapOf(): SynchronizedMap = SynchronizedMap(linkedMapOf()) /** Returns a new [SynchronizedMap] with the specified contents. */ +@Deprecated( + "Prefer using atomicMapOf instead. SynchronizedMap does not play nicely with coroutines.", + ReplaceWith("atomicMapOf", "com.juul.tuulbox.collections.atomicMapOf"), +) public fun synchronizedMapOf(vararg pairs: Pair): SynchronizedMap = SynchronizedMap(linkedMapOf(*pairs)) /** A [MutableMap] where all reads and writes are protected by a [ReentrantLock]. */ +@Deprecated( + "Prefer using AtomicMap instead. SynchronizedMap does not play nicely with coroutines.", + ReplaceWith("AtomicMap", "com.juul.tuulbox.collections.AtomicMap"), +) public class SynchronizedMap internal constructor( private val inner: LinkedHashMap, ) : MutableMap { diff --git a/collections/src/commonTest/kotlin/AtomicMapTests.kt b/collections/src/commonTest/kotlin/AtomicMapTests.kt new file mode 100644 index 00000000..25deaa37 --- /dev/null +++ b/collections/src/commonTest/kotlin/AtomicMapTests.kt @@ -0,0 +1,44 @@ +package com.juul.tuulbox.collections + +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +public class AtomicMapTests { + + @Test + public fun atomicMap_concurrentMutateBlocks_doesNotLoseWrites() = runTest { + val actual = AtomicMap(persistentMapOf()) + (1..10).map { + launch(Dispatchers.Default) { + for (i in 0 until 500) { + actual.mutate { this["count"] = (this["count"] ?: 0) + 1 } + } + } + }.joinAll() + + assertEquals(mapOf("count" to 5_000), actual) + } + + @Test + public fun atomicMap_snapshotAndMutate_returnsPreviousSnapshot() = runTest { + val atomic = AtomicMap(persistentMapOf()) + val actual = atomic.snapshotAndMutate { + put(0, 0) + } + assertEquals(emptyMap(), actual) + } + + @Test + public fun atomicMap_mutateAndSnapshot_returnsNewSnapshot() = runTest { + val atomic = AtomicMap(persistentMapOf()) + val actual = atomic.mutateAndSnapshot { + put(0, 0) + } + assertEquals(mapOf(0 to 0), actual) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc811018..e79a11a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ ktor = "2.3.7" [libraries] androidx-core = { module = "androidx.core:core", version = "1.12.0" } androidx-startup = { module = "androidx.startup:startup-runtime", version = "1.1.1" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.3.7" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }