From ff4440243be55ae2325fc8fcf7224298abef70f3 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 28 Nov 2024 21:22:44 +0100 Subject: [PATCH 01/36] [wip] add some metrics computation code setup --- .../flowmvi/metrics/P2QuantileEstimator.kt | 149 ++++++++++++++++++ .../flowmvi/metrics/PerformanceMetrics.kt | 65 ++++++++ 2 files changed, 214 insertions(+) create mode 100644 metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/P2QuantileEstimator.kt create mode 100644 metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/PerformanceMetrics.kt diff --git a/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/P2QuantileEstimator.kt b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/P2QuantileEstimator.kt new file mode 100644 index 00000000..ba3ce577 --- /dev/null +++ b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/P2QuantileEstimator.kt @@ -0,0 +1,149 @@ +package pro.respawn.flowmvi.metrics + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlin.math.abs +import kotlin.math.round +import kotlin.math.sign + +/** + * P2quantile algorithm implementation to estimate median values. + * Credit: https://aakinshin.net/posts/ex-p2-quantile-estimator/ + * Accessed on 2024-11-27 + */ +internal class P2QuantileEstimator(private vararg val probabilities: Double) : SynchronizedObject() { + + private val m: Int = probabilities.size + private val markerCount: Int = 2 * m + 3 + private val n: IntArray = IntArray(markerCount) + private val ns: DoubleArray = DoubleArray(markerCount) + private val q: DoubleArray = DoubleArray(markerCount) + + var count: Int = 0 + private set + + fun add(value: Double) = synchronized(this) { + if (count < markerCount) { + q[count++] = value + if (count == markerCount) { + q.sort() + + updateNs(markerCount - 1) + for (i in 0 until markerCount) { + n[i] = round(ns[i]).toInt() + } + q.copyInto(ns) + for (i in 0 until markerCount) { + q[i] = ns[n[i]] + } + updateNs(markerCount - 1) + } + return@synchronized + } + + var k = -1 + if (value < q[0]) { + q[0] = value + k = 0 + } else { + for (i in 1 until markerCount) { + if (value < q[i]) { + k = i - 1 + break + } + } + if (k == -1) { + q[markerCount - 1] = value + k = markerCount - 2 + } + } + + for (i in k + 1 until markerCount) { + n[i]++ + } + updateNs(count) + + var leftI = 1 + var rightI = markerCount - 2 + while (leftI <= rightI) { + val i: Int + if (abs(ns[leftI] / count - 0.5) <= abs(ns[rightI] / count - 0.5)) { + i = leftI + leftI++ + } else { + i = rightI + rightI-- + } + adjust(i) + } + + count++ + } + + private fun updateNs(maxIndex: Int) { + // Principal markers + ns[0] = 0.0 + for (i in 0 until m) { + ns[i * 2 + 2] = maxIndex * probabilities[i] + } + ns[markerCount - 1] = maxIndex.toDouble() + + // Middle markers + ns[1] = maxIndex * probabilities[0] / 2.0 + for (i in 1 until m) { + ns[2 * i + 1] = maxIndex * (probabilities[i - 1] + probabilities[i]) / 2.0 + } + ns[markerCount - 2] = maxIndex * (1 + probabilities[m - 1]) / 2.0 + } + + private fun adjust(i: Int) { + val d = ns[i] - n[i] + if (d >= 1 && n[i + 1] - n[i] > 1 || d <= -1 && n[i - 1] - n[i] < -1) { + val dInt = d.sign.toInt() + val qs = parabolic(i, dInt.toDouble()) + if (q[i - 1] < qs && qs < q[i + 1]) { + q[i] = qs + } else { + q[i] = linear(i, dInt) + } + n[i] += dInt + } + } + + private fun parabolic(i: Int, d: Double): Double { + val nPlus1 = n[i + 1].toDouble() + val nMinus1 = n[i - 1].toDouble() + val nI = n[i].toDouble() + + val qPlus1 = q[i + 1] + val qMinus1 = q[i - 1] + val qI = q[i] + + val numerator = (nI - nMinus1 + d) * (qPlus1 - qI) / (nPlus1 - nI) + + (nPlus1 - nI - d) * (qI - qMinus1) / (nI - nMinus1) + + return qI + d / (nPlus1 - nMinus1) * numerator + } + + private fun linear(i: Int, d: Int) = q[i] + d * (q[i + d] - q[i]) / (n[i + d] - n[i]).toDouble() + + fun getQuantile(p: Double): Double = synchronized(this) { + if (count == 0) return Double.NaN + if (count <= markerCount) { + q.sort(0, count) + val index = round((count - 1) * p).toInt() + return q[index] + } + + for (i in probabilities.indices) { + if (probabilities[i] == p) + return q[2 * i + 2] + } + + throw IllegalStateException("Target quantile ($p) wasn't requested in the constructor") + } + + fun clear() { + count = 0 + } +} diff --git a/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/PerformanceMetrics.kt b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/PerformanceMetrics.kt new file mode 100644 index 00000000..20917527 --- /dev/null +++ b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/PerformanceMetrics.kt @@ -0,0 +1,65 @@ +package pro.respawn.flowmvi.metrics + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.datetime.Clock +import kotlin.math.min +import kotlin.time.Duration.Companion.seconds + +internal class PerformanceMetrics : SynchronizedObject() { + + // For Average Time using EMA + private var ema: Double = 0.0 + private val alpha: Double = 0.1 // Smoothing factor + + // For Median Time using P² Algorithm + private var p2: P2QuantileEstimator = P2QuantileEstimator(0.5) // Median + + var totalOperations: Long = 0 + private set + + private val numberOfBuckets: Int = 60 // Last 60 seconds + private val frequencyBuckets = IntArray(numberOfBuckets) + private val bucketDurationMillis = 1.seconds // 1 second per bucket + private var lastBucketTime = Clock.System.now() + + // Call this method when an operation is measured + fun recordOperation(durationMillis: Long) = synchronized(this) { + totalOperations++ + + // Update EMA + ema = if (ema == 0.0) durationMillis.toDouble() else alpha * durationMillis + (1 - alpha) * ema + + // Update Median Estimate + p2.add(durationMillis.toDouble()) + + // Update Frequency Counter + updateFrequencyCounter() + } + + private fun updateFrequencyCounter() = synchronized(this) { + val currentTime = Clock.System.now() + val elapsedBuckets = ((currentTime - lastBucketTime) / bucketDurationMillis).toInt() + + if (elapsedBuckets > 0) { + // Shift the buckets + val shift = min(elapsedBuckets, numberOfBuckets) + frequencyBuckets.copyInto(frequencyBuckets, shift, 0, numberOfBuckets - shift) + for (i in numberOfBuckets - shift until numberOfBuckets) { + frequencyBuckets[i] = 0 + } + lastBucketTime += bucketDurationMillis * elapsedBuckets + } + + // Increment the current bucket + frequencyBuckets[numberOfBuckets - 1]++ + } + + val averageTime get() = ema + + fun medianTime(q: Double): Double = p2.getQuantile(q) + + fun opsPerSecond(): Double = synchronized(this) { + return frequencyBuckets.sum().toDouble() / numberOfBuckets + } +} From c58b69207d21a8341e1dc55ce61b551d3d9e7bca Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 28 Nov 2024 21:23:56 +0100 Subject: [PATCH 02/36] chore: setup benchmarks module --- android/build.gradle.kts | 2 +- benchmarks/build.gradle.kts | 18 ++++++++++++++ build.gradle.kts | 14 +++++------ compose/build.gradle.kts | 2 +- core/build.gradle.kts | 6 +++++ debugger/app/build.gradle.kts | 2 +- debugger/debugger-client/build.gradle.kts | 2 +- debugger/debugger-common/build.gradle.kts | 2 +- debugger/debugger-plugin/build.gradle.kts | 2 +- debugger/server/build.gradle.kts | 4 ++-- essenty/essenty-compose/build.gradle.kts | 2 +- gradle/libs.versions.toml | 7 ++++-- metrics/build.gradle.kts | 29 +++++++++++++++++++++++ sample/build.gradle.kts | 4 ++-- savedstate/build.gradle.kts | 6 ++--- settings.gradle.kts | 2 ++ 16 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 benchmarks/build.gradle.kts create mode 100644 metrics/build.gradle.kts diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 80c5c53c..02d224bb 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id(libs.plugins.kotlinMultiplatform.id) + id(libs.plugins.kotlin.multiplatform.id) id(libs.plugins.androidLibrary.id) alias(libs.plugins.maven.publish) dokkaDocumentation diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts new file mode 100644 index 00000000..ecc0b8f2 --- /dev/null +++ b/benchmarks/build.gradle.kts @@ -0,0 +1,18 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id(libs.plugins.kotlin.multiplatform.id) +} + +kotlin { + configureMultiplatform( + ext = this, + wasmWasi = false, + android = false, + ) +} + +dependencies { + commonMainImplementation(projects.core) +} diff --git a/build.gradle.kts b/build.gradle.kts index 900a4e3a..27fe5c7d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,16 +16,18 @@ plugins { alias(libs.plugins.detekt) // alias(libs.plugins.gradleDoctor) alias(libs.plugins.version.catalog.update) - alias(libs.plugins.atomicfu) // alias(libs.plugins.dependencyAnalysis) - alias(libs.plugins.serialization) apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.compose) apply false alias(libs.plugins.maven.publish) apply false + alias(libs.plugins.atomicfu) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.benchmark) apply false // plugins already on a classpath (conventions) // alias(libs.plugins.androidApplication) apply false // alias(libs.plugins.androidLibrary) apply false + // alias(libs.plugins.kotlin.multiplatform) apply false // alias(libs.plugins.kotlinMultiplatform) apply false - alias(libs.plugins.compose.compiler) apply false id(libs.plugins.dokka.id) } @@ -140,11 +142,7 @@ versionCatalogUpdate { } } -atomicfu { - dependenciesVersion = libs.versions.kotlinx.atomicfu.get() - transformJvm = true - jvmVariant = "VH" -} + tasks { withType().configureEach { diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index ecf5beb9..291a2c7f 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { - id(libs.plugins.kotlinMultiplatform.id) + id(libs.plugins.kotlin.multiplatform.id) id(libs.plugins.androidLibrary.id) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c1756f96..30f24b95 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -9,6 +9,12 @@ plugins { dokkaDocumentation } +atomicfu { + dependenciesVersion = libs.versions.kotlinx.atomicfu.get() + transformJvm = true + jvmVariant = "VH" +} + android { namespace = Config.namespace } diff --git a/debugger/app/build.gradle.kts b/debugger/app/build.gradle.kts index 2862a76d..e0548598 100644 --- a/debugger/app/build.gradle.kts +++ b/debugger/app/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { - id(libs.plugins.kotlinMultiplatform.id) + id(libs.plugins.kotlin.multiplatform.id) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) } diff --git a/debugger/debugger-client/build.gradle.kts b/debugger/debugger-client/build.gradle.kts index 19d7b6fd..672b18db 100644 --- a/debugger/debugger-client/build.gradle.kts +++ b/debugger/debugger-client/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("multiplatform") id("com.android.library") - alias(libs.plugins.serialization) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.maven.publish) dokkaDocumentation } diff --git a/debugger/debugger-common/build.gradle.kts b/debugger/debugger-common/build.gradle.kts index 8dc2e24a..58d61742 100644 --- a/debugger/debugger-common/build.gradle.kts +++ b/debugger/debugger-common/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("multiplatform") id("com.android.library") - alias(libs.plugins.serialization) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.maven.publish) dokkaDocumentation } diff --git a/debugger/debugger-plugin/build.gradle.kts b/debugger/debugger-plugin/build.gradle.kts index 04ad0f78..859c216c 100644 --- a/debugger/debugger-plugin/build.gradle.kts +++ b/debugger/debugger-plugin/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("multiplatform") id("com.android.library") - alias(libs.plugins.serialization) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.maven.publish) dokkaDocumentation } diff --git a/debugger/server/build.gradle.kts b/debugger/server/build.gradle.kts index cb28e9ac..6dcdda58 100644 --- a/debugger/server/build.gradle.kts +++ b/debugger/server/build.gradle.kts @@ -1,10 +1,10 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { - id(libs.plugins.kotlinMultiplatform.id) + id(libs.plugins.kotlin.multiplatform.id) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) - alias(libs.plugins.serialization) + alias(libs.plugins.kotlin.serialization) } val parentNamespace = namespaceByPath() diff --git a/essenty/essenty-compose/build.gradle.kts b/essenty/essenty-compose/build.gradle.kts index a1960514..aeac63d6 100644 --- a/essenty/essenty-compose/build.gradle.kts +++ b/essenty/essenty-compose/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id(libs.plugins.kotlinMultiplatform.id) + id(libs.plugins.kotlin.multiplatform.id) id(libs.plugins.androidLibrary.id) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c2f6c73..c2391851 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ serialization = "1.7.3" turbine = "1.2.0" uuid = "0.8.4" versionCatalogUpdatePlugin = "0.8.5" +kotlin-benchmark = "0.4.13" [libraries] android-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradleAndroid" } @@ -68,6 +69,7 @@ kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-c kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } +kotlin-benchmark = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlin-benchmark" } ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } @@ -144,7 +146,8 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } gradleDoctor = { id = "com.osacky.doctor", version.ref = "gradleDoctorPlugin" } intellij-ide = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-ide-plugin" } kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" } -kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish-plugin" } -serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" } +kotlin-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kotlin-benchmark" } diff --git a/metrics/build.gradle.kts b/metrics/build.gradle.kts new file mode 100644 index 00000000..1eaa96a3 --- /dev/null +++ b/metrics/build.gradle.kts @@ -0,0 +1,29 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id(libs.plugins.kotlin.multiplatform.id) + id(libs.plugins.androidLibrary.id) + alias(libs.plugins.atomicfu) + alias(libs.plugins.maven.publish) + alias(libs.plugins.kotlin.benchmark) + dokkaDocumentation +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + configureMultiplatform( + ext = this, + wasmWasi = false, // datetime does not support wasmWasi + ) +} + +android { + configureAndroidLibrary(this) + namespace = "${Config.namespace}.metrics" +} + +dependencies { + commonMainImplementation(libs.kotlin.datetime) + commonMainImplementation(libs.kotlin.atomicfu) +} diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 61608746..25cbca8e 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -3,11 +3,11 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { - id(libs.plugins.kotlinMultiplatform.id) + id(libs.plugins.kotlin.multiplatform.id) id(applibs.plugins.android.application.id) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) - alias(libs.plugins.serialization) + alias(libs.plugins.kotlin.serialization) } // region buildconfig diff --git a/savedstate/build.gradle.kts b/savedstate/build.gradle.kts index 4f7d9aef..1b6e6203 100644 --- a/savedstate/build.gradle.kts +++ b/savedstate/build.gradle.kts @@ -1,9 +1,9 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { - kotlin("multiplatform") - alias(libs.plugins.serialization) - id("com.android.library") + alias(libs.plugins.kotlin.serialization) + id(libs.plugins.kotlin.multiplatform.id) + id(libs.plugins.androidLibrary.id) alias(libs.plugins.maven.publish) dokkaDocumentation } diff --git a/settings.gradle.kts b/settings.gradle.kts index b6fdc777..fd04fc5d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,8 @@ include(":core") include(":android") include(":compose") include(":savedstate") +include(":metrics") +include(":benchmarks") include(":essenty") include(":essenty:essenty-compose") include(":debugger:app") From 20be1c29a6e435ad61bf62c94bbd5ff7a0c0da78 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 28 Nov 2024 23:27:51 +0100 Subject: [PATCH 03/36] feat: set up benchmark comparing FMVI and traditional setup --- .idea/runConfigurations/All_benchmarks.xml | 24 +++++++++ benchmarks/build.gradle.kts | 39 ++++++++++++++- .../flowmvi/benchmarks/BenchmarkDefaults.kt | 6 +++ .../benchmarks/setup/BenchmarkIntent.kt | 7 +++ .../benchmarks/setup/BenchmarkState.kt | 7 +++ .../channelbased/ChannelBasedMVIBenchmark.kt | 42 ++++++++++++++++ .../setup/channelbased/TraditionalMVIStore.kt | 31 ++++++++++++ .../setup/optimized/OptimizedFMVIBenchmark.kt | 49 +++++++++++++++++++ .../setup/optimized/OptimizedStore.kt | 33 +++++++++++++ .../setup/parallel/ParallelStore.kt | 31 ++++++++++++ .../traditional/TraditionalMVIBenchmark.kt | 30 ++++++++++++ .../setup/traditional/TraditionalMVIStore.kt | 19 +++++++ .../src/main/kotlin/ConfigureMultiplatform.kt | 3 +- gradle/libs.versions.toml | 2 + metrics/build.gradle.kts | 1 - 15 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 .idea/runConfigurations/All_benchmarks.xml create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkIntent.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkState.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedMVIBenchmark.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIStore.kt diff --git a/.idea/runConfigurations/All_benchmarks.xml b/.idea/runConfigurations/All_benchmarks.xml new file mode 100644 index 00000000..a816025e --- /dev/null +++ b/.idea/runConfigurations/All_benchmarks.xml @@ -0,0 +1,24 @@ + + + + + + + false + true + false + true + + + \ No newline at end of file diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index ecc0b8f2..d6db3c0a 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -1,18 +1,55 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import kotlinx.benchmark.gradle.JvmBenchmarkTarget +import kotlinx.benchmark.gradle.benchmark @Suppress("DSL_SCOPE_VIOLATION") plugins { id(libs.plugins.kotlin.multiplatform.id) + alias(libs.plugins.kotlin.benchmark) + alias(libs.plugins.kotlin.allopen) } +allOpen { // jmh benchmark classes must be open + annotation("org.openjdk.jmh.annotations.State") +} kotlin { configureMultiplatform( ext = this, wasmWasi = false, android = false, + explicitApi = false, ) + +} + +tasks.withType().configureEach { + jvmArgs("-Dkotlinx.coroutines.debug=off") } dependencies { commonMainImplementation(projects.core) + + commonMainImplementation(libs.kotlin.coroutines.test) + commonMainImplementation(libs.kotlin.test) + commonMainImplementation(libs.kotlin.benchmark) +} + +benchmark { + configurations { + named("main") { + iterations = 100 + warmups = 5 + iterationTime = 100 + iterationTimeUnit = "ms" + outputTimeUnit = "us" + mode = "avgt" + reportFormat = "text" + // advanced("nativeGCAfterIteration", true) + } + } + targets { + register("jvm") { + this as JvmBenchmarkTarget + jmhVersion = libs.versions.jmh.get() + } + } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt new file mode 100644 index 00000000..6f516ad1 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt @@ -0,0 +1,6 @@ +package pro.respawn.flowmvi.benchmarks + +internal object BenchmarkDefaults { + + const val intentsPerIteration = 1000 +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkIntent.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkIntent.kt new file mode 100644 index 00000000..7fe05393 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkIntent.kt @@ -0,0 +1,7 @@ +package pro.respawn.flowmvi.benchmarks.setup + +import pro.respawn.flowmvi.api.MVIIntent + +internal sealed interface BenchmarkIntent : MVIIntent { + data object Increment : BenchmarkIntent +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkState.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkState.kt new file mode 100644 index 00000000..5ca89d19 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkState.kt @@ -0,0 +1,7 @@ +package pro.respawn.flowmvi.benchmarks.setup + +import pro.respawn.flowmvi.api.MVIState + +data class BenchmarkState( + val counter: Int = 0 +) : MVIState diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedMVIBenchmark.kt new file mode 100644 index 00000000..2cdd3359 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedMVIBenchmark.kt @@ -0,0 +1,42 @@ +package pro.respawn.flowmvi.benchmarks.setup.channelbased + +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.optimized.optimizedStore + +@Suppress("unused") +@State(Scope.Benchmark) +internal class ChannelBasedMVIBenchmark { + + lateinit var store: ChannelBasedTraditionalStore + lateinit var scope: CoroutineScope + + @Setup + fun setup() { + scope = CoroutineScope(Dispatchers.Unconfined) + store = ChannelBasedTraditionalStore(scope) + } + + @Benchmark + fun benchmark() = runBlocking { + repeat(BenchmarkDefaults.intentsPerIteration) { + store.onIntent(BenchmarkIntent.Increment) + } + store.state.first { state -> state.counter >= BenchmarkDefaults.intentsPerIteration } + } + + @TearDown + fun teardown() = runBlocking { + scope.cancel() + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt new file mode 100644 index 00000000..f32219ea --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt @@ -0,0 +1,31 @@ +package pro.respawn.flowmvi.benchmarks.setup.channelbased + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState + +internal class ChannelBasedTraditionalStore(scope: CoroutineScope) { + + private val _state = MutableStateFlow(BenchmarkState()) + val state = _state.asStateFlow() + val intents = Channel() + + init { + scope.launch { + for (intent in intents) reduce(intent) + } + } + + fun onIntent(intent: BenchmarkIntent) = intents.trySend(intent) + + private fun reduce(intent: BenchmarkIntent) = when (intent) { + is BenchmarkIntent.Increment -> _state.update { state -> + state.copy(counter = state.counter + 1) + } + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt new file mode 100644 index 00000000..f152180c --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt @@ -0,0 +1,49 @@ +package pro.respawn.flowmvi.benchmarks.setup.optimized + +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState +import pro.respawn.flowmvi.dsl.collect + +@Suppress("unused") +@State(Scope.Benchmark) +internal class OptimizedFMVIBenchmark { + + lateinit var store: Store + lateinit var scope: CoroutineScope + + @Setup + fun setup() = runBlocking { + scope = CoroutineScope(Dispatchers.Unconfined) + store = optimizedStore(scope) + store.awaitStartup() + } + + @Benchmark + fun benchmark() = runBlocking { + repeat(BenchmarkDefaults.intentsPerIteration) { + store.intent(BenchmarkIntent.Increment) + } + store.collect { states.first { it.counter >= BenchmarkDefaults.intentsPerIteration } } + } + + @TearDown + fun teardown() = runBlocking { + scope.cancel() + store.closeAndWait() + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt new file mode 100644 index 00000000..9c6fba40 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt @@ -0,0 +1,33 @@ +package pro.respawn.flowmvi.benchmarks.setup.optimized + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import pro.respawn.flowmvi.api.ActionShareBehavior.Disabled +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.plugins.reduce + +internal inline fun optimizedStore( + scope: CoroutineScope, +) = store(BenchmarkState(), scope) { + configure { + logger = null + debuggable = false + actionShareBehavior = Disabled + atomicStateUpdates = false + parallelIntents = false + verifyPlugins = false + onOverflow = BufferOverflow.DROP_OLDEST + intentCapacity = Channel.RENDEZVOUS + } + reduce { + when (it) { + is Increment -> updateStateImmediate { + copy(counter = counter + 1) + } + } + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt new file mode 100644 index 00000000..36b2a3c4 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt @@ -0,0 +1,31 @@ +package pro.respawn.flowmvi.benchmarks.setup.parallel + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import pro.respawn.flowmvi.api.ActionShareBehavior +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState +import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.plugins.reduce + +private fun StoreBuilder<*, *, *>.config() = configure { + logger = null + debuggable = false + actionShareBehavior = ActionShareBehavior.Disabled + atomicStateUpdates = true + parallelIntents = true + verifyPlugins = false + onOverflow = BufferOverflow.SUSPEND + intentCapacity = Channel.UNLIMITED +} + +internal fun atomicParallelStore() = store(BenchmarkState()) { + config() + reduce { + when (it) { + is Increment -> updateState { copy(counter = counter + 1) } + } + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt new file mode 100644 index 00000000..be5d8f46 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt @@ -0,0 +1,30 @@ +package pro.respawn.flowmvi.benchmarks.setup.traditional + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent + +@Suppress("unused") +@State(Scope.Benchmark) +internal class TraditionalMVIBenchmark { + + var store = TraditionalMVIStore() + + @Setup + fun setup() { + store = TraditionalMVIStore() + } + + @Benchmark + fun benchmark() = runBlocking { + repeat(BenchmarkDefaults.intentsPerIteration) { + store.onIntent(BenchmarkIntent.Increment) + } + store.state.first { state -> state.counter >= BenchmarkDefaults.intentsPerIteration } + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIStore.kt new file mode 100644 index 00000000..d4a7d9de --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIStore.kt @@ -0,0 +1,19 @@ +package pro.respawn.flowmvi.benchmarks.setup.traditional + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState + +internal class TraditionalMVIStore { + + private val _state = MutableStateFlow(BenchmarkState()) + val state = _state.asStateFlow() + + fun onIntent(intent: BenchmarkIntent) = when (intent) { + is BenchmarkIntent.Increment -> _state.update { state -> + state.copy(counter = state.counter + 1) + } + } +} diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt index 7e98d1cd..771ca2ec 100644 --- a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt @@ -22,10 +22,11 @@ fun Project.configureMultiplatform( windows: Boolean = true, wasmJs: Boolean = true, wasmWasi: Boolean = true, + explicitApi: Boolean = true, configure: KotlinHierarchyBuilder.Root.() -> Unit = {}, ) = ext.apply { val libs by versionCatalog - explicitApi() + if(explicitApi) explicitApi() applyDefaultHierarchyTemplate(configure) withSourcesJar(true) compilerOptions { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2391851..d9d51f2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ intellij-ide-plugin = "2.1.0" intellij-idea = "2024.1" junit = "4.13.2" kotest = "6.0.0.M1" +jmh = "1.37" # @pin kotlin = "2.1.0" kotlin-collections = "0.3.8" @@ -147,6 +148,7 @@ gradleDoctor = { id = "com.osacky.doctor", version.ref = "gradleDoctorPlugin" } intellij-ide = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-ide-plugin" } kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish-plugin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" } diff --git a/metrics/build.gradle.kts b/metrics/build.gradle.kts index 1eaa96a3..0e70684e 100644 --- a/metrics/build.gradle.kts +++ b/metrics/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id(libs.plugins.androidLibrary.id) alias(libs.plugins.atomicfu) alias(libs.plugins.maven.publish) - alias(libs.plugins.kotlin.benchmark) dokkaDocumentation } From 7fb8a5e9dbde3f2ee01491e750dcb3028b8820f4 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Fri, 29 Nov 2024 00:28:36 +0100 Subject: [PATCH 04/36] [wip] compare performance with fluxo --- benchmarks/build.gradle.kts | 18 +++++++-- .../benchmarks/setup/fluxo/FluxoBenchmark.kt | 39 +++++++++++++++++++ .../benchmarks/setup/fluxo/FluxoStore.kt | 19 +++++++++ settings.gradle.kts | 3 +- 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoBenchmark.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index d6db3c0a..2e4dd280 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -1,3 +1,4 @@ +import configureMultiplatform import kotlinx.benchmark.gradle.JvmBenchmarkTarget import kotlinx.benchmark.gradle.benchmark @@ -14,20 +15,29 @@ allOpen { // jmh benchmark classes must be open kotlin { configureMultiplatform( ext = this, + explicitApi = false, wasmWasi = false, android = false, - explicitApi = false, + linux = false, + iOs = false, + macOs = false, + watchOs = false, + tvOs = false, + windows = false, + wasmJs = false, ) } - tasks.withType().configureEach { jvmArgs("-Dkotlinx.coroutines.debug=off") } - dependencies { commonMainImplementation(projects.core) + val fluxo = "0.1-2306082-SNAPSHOT" + //noinspection UseTomlInstead + commonMainImplementation("io.github.fluxo-kt:fluxo-core:$fluxo") + commonMainImplementation(libs.kotlin.coroutines.test) commonMainImplementation(libs.kotlin.test) commonMainImplementation(libs.kotlin.benchmark) @@ -37,7 +47,7 @@ benchmark { configurations { named("main") { iterations = 100 - warmups = 5 + warmups = 10 iterationTime = 100 iterationTimeUnit = "ms" outputTimeUnit = "us" diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoBenchmark.kt new file mode 100644 index 00000000..272a923a --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoBenchmark.kt @@ -0,0 +1,39 @@ +package pro.respawn.flowmvi.benchmarks.setup.fluxo + +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kt.fluxo.core.closeAndWait +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState + +@Suppress("unused") +@State(Scope.Benchmark) +internal class FluxoBenchmark { + + lateinit var store: kt.fluxo.core.Store + + @Setup + fun setup() = runBlocking { + store = fluxoStore() + } + + @Benchmark + fun benchmark() = runBlocking { + repeat(BenchmarkDefaults.intentsPerIteration) { + store.send(BenchmarkIntent.Increment) + } + store.first { state -> state.counter >= BenchmarkDefaults.intentsPerIteration } + } + + @TearDown + fun teardown() = runBlocking { + store.closeAndWait() + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt new file mode 100644 index 00000000..45478182 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt @@ -0,0 +1,19 @@ +package pro.respawn.flowmvi.benchmarks.setup.fluxo + +import kotlinx.coroutines.Dispatchers +import kt.fluxo.core.annotation.ExperimentalFluxoApi +import kt.fluxo.core.store +import kt.fluxo.core.updateState +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState + +@OptIn(ExperimentalFluxoApi::class) +internal inline fun fluxoStore( +) = store(BenchmarkState(), handler = { it: BenchmarkIntent -> + updateState { it.copy(counter = it.counter + 1) } +}) { + coroutineContext = Dispatchers.Unconfined + intentStrategy = Direct + debugChecks = false + lazy = false +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fd04fc5d..f50c45b1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,9 +16,10 @@ dependencyResolutionManagement { // REQUIRED for IDE module configuration to resolve IDE platform repositoriesMode = RepositoriesMode.PREFER_PROJECT repositories { - mavenLocal() + // mavenLocal() google() mavenCentral() + maven("https://s01.oss.sonatype.org/content/repositories/snapshots/") } versionCatalogs { From 34edd5213e54afdcd07adb66303da4cdee1d64f3 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Fri, 29 Nov 2024 00:38:55 +0100 Subject: [PATCH 05/36] enable parallel benchmark --- .../setup/parallel/ParallelFMVIBenchmark.kt | 46 +++++++++++++++++++ .../setup/parallel/ParallelStore.kt | 5 +- .../respawn/flowmvi/modules/IntentModule.kt | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt new file mode 100644 index 00000000..fdd50342 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt @@ -0,0 +1,46 @@ +package pro.respawn.flowmvi.benchmarks.setup.parallel + +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState +import pro.respawn.flowmvi.dsl.collect + +@Suppress("unused") +@State(Scope.Benchmark) +internal class ParallelFMVIBenchmark { + + lateinit var store: Store + lateinit var scope: CoroutineScope + + @Setup + fun setup() = runBlocking { + scope = CoroutineScope(Dispatchers.Unconfined) + store = atomicParallelStore(scope) + store.awaitStartup() + } + + @Benchmark + fun benchmark() = runBlocking { + repeat(BenchmarkDefaults.intentsPerIteration) { + store.intent(BenchmarkIntent.Increment) + } + store.collect { states.first { it.counter >= BenchmarkDefaults.intentsPerIteration } } + } + + @TearDown + fun teardown() = runBlocking { + scope.cancel() + store.closeAndWait() + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt index 36b2a3c4..6aa5cf93 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt @@ -1,5 +1,6 @@ package pro.respawn.flowmvi.benchmarks.setup.parallel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import pro.respawn.flowmvi.api.ActionShareBehavior @@ -21,7 +22,9 @@ private fun StoreBuilder<*, *, *>.config() = configure { intentCapacity = Channel.UNLIMITED } -internal fun atomicParallelStore() = store(BenchmarkState()) { +internal fun atomicParallelStore( + scope: CoroutineScope +) = store(BenchmarkState(), scope) { config() reduce { when (it) { diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt index 2420982b..b18741e1 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt @@ -47,7 +47,7 @@ private class SequentialChannelIntentModule( // must always suspend the current scope to wait for intents for (intent in intents) { onIntent(intent) - yield() + yield() // TODO: Accounts for 50% performance loss, way to get rid of? Why needed? } } } From 09b3dc73a57ebae15db9512a94e2a7c96cdcbb9d Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Fri, 29 Nov 2024 12:11:27 +0100 Subject: [PATCH 06/36] use better optimized fluxo store config for benchmarks --- .../respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt index 45478182..fbc1c66d 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt @@ -3,14 +3,15 @@ package pro.respawn.flowmvi.benchmarks.setup.fluxo import kotlinx.coroutines.Dispatchers import kt.fluxo.core.annotation.ExperimentalFluxoApi import kt.fluxo.core.store -import kt.fluxo.core.updateState import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState @OptIn(ExperimentalFluxoApi::class) internal inline fun fluxoStore( -) = store(BenchmarkState(), handler = { it: BenchmarkIntent -> - updateState { it.copy(counter = it.counter + 1) } +) = store(BenchmarkState(), reducer = { it: BenchmarkIntent -> + when (it) { + BenchmarkIntent.Increment -> copy(counter = counter + 1) + } }) { coroutineContext = Dispatchers.Unconfined intentStrategy = Direct From 7a52c130adcccfd7dee270fb6f2a94ce77fc36ea Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Fri, 29 Nov 2024 12:11:48 +0100 Subject: [PATCH 07/36] add start/stop benchmarks for fmvi and fluxo --- benchmarks/build.gradle.kts | 5 ++-- ...k.kt => ChannelTraditionalMVIBenchmark.kt} | 3 +- ...xoBenchmark.kt => FluxoIntentBenchmark.kt} | 5 ++-- .../setup/fluxo/FluxoStartStopBenchmark.kt | 22 ++++++++++++++ .../setup/optimized/OptimizedFMVIBenchmark.kt | 5 ++-- .../OptimizedFMVIStartStopBenchmark.kt | 19 ++++++++++++ .../setup/optimized/OptimizedStore.kt | 29 ++++++++++++++----- .../setup/parallel/ParallelFMVIBenchmark.kt | 2 ++ .../traditional/TraditionalMVIBenchmark.kt | 2 ++ 9 files changed, 75 insertions(+), 17 deletions(-) rename benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/{ChannelBasedMVIBenchmark.kt => ChannelTraditionalMVIBenchmark.kt} (91%) rename benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/{FluxoBenchmark.kt => FluxoIntentBenchmark.kt} (91%) create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIStartStopBenchmark.kt diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 2e4dd280..73dd8521 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -50,10 +50,11 @@ benchmark { warmups = 10 iterationTime = 100 iterationTimeUnit = "ms" - outputTimeUnit = "us" - mode = "avgt" + outputTimeUnit = "ms" + mode = "thrpt" // "thrpt" - throughput, "avgt" - average reportFormat = "text" // advanced("nativeGCAfterIteration", true) + // advanced("jvmForks", "definedByJmh") } } targets { diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt similarity index 91% rename from benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedMVIBenchmark.kt rename to benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt index 2cdd3359..5884e3ac 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt @@ -12,11 +12,10 @@ import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent -import pro.respawn.flowmvi.benchmarks.setup.optimized.optimizedStore @Suppress("unused") @State(Scope.Benchmark) -internal class ChannelBasedMVIBenchmark { +internal class ChannelTraditionalMVIBenchmark { lateinit var store: ChannelBasedTraditionalStore lateinit var scope: CoroutineScope diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt similarity index 91% rename from benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoBenchmark.kt rename to benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt index 272a923a..e084421a 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt @@ -8,14 +8,15 @@ import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State -import pro.respawn.flowmvi.api.Store +import org.openjdk.jmh.annotations.Threads import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState +@Threads(Threads.MAX) @Suppress("unused") @State(Scope.Benchmark) -internal class FluxoBenchmark { +internal class FluxoIntentBenchmark { lateinit var store: kt.fluxo.core.Store diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt new file mode 100644 index 00000000..b720345a --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt @@ -0,0 +1,22 @@ +package pro.respawn.flowmvi.benchmarks.setup.fluxo + +import kotlinx.benchmark.Benchmark +import kotlinx.coroutines.runBlocking +import kt.fluxo.core.annotation.ExperimentalFluxoApi +import kt.fluxo.core.closeAndWait +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads + +@Threads(Threads.MAX) +@State(Scope.Benchmark) +class FluxoStartStopBenchmark { + + @OptIn(ExperimentalFluxoApi::class) + @Benchmark + fun benchmark() = runBlocking { + val store = fluxoStore() + store.start().join() + store.closeAndWait() + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt index f152180c..907300d5 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt @@ -1,24 +1,23 @@ package pro.respawn.flowmvi.benchmarks.setup.optimized import kotlinx.benchmark.TearDown -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.openjdk.jmh.annotations.Benchmark -import org.openjdk.jmh.annotations.Fork import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads import pro.respawn.flowmvi.api.Store import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState import pro.respawn.flowmvi.dsl.collect +@Threads(Threads.MAX) @Suppress("unused") @State(Scope.Benchmark) internal class OptimizedFMVIBenchmark { diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIStartStopBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIStartStopBenchmark.kt new file mode 100644 index 00000000..e6d0e2fa --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIStartStopBenchmark.kt @@ -0,0 +1,19 @@ +package pro.respawn.flowmvi.benchmarks.setup.optimized + +import kotlinx.benchmark.Benchmark +import kotlinx.coroutines.runBlocking +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads + +@Threads(Threads.MAX) +@State(Scope.Benchmark) +internal class OptimizedFMVIStartStopBenchmark { + + @Benchmark + fun benchmark() = runBlocking { + val store = optimizedStore() + store.start(this).awaitStartup() + store.closeAndWait() + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt index 9c6fba40..8121eb50 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt @@ -7,12 +7,12 @@ import pro.respawn.flowmvi.api.ActionShareBehavior.Disabled import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState +import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.store import pro.respawn.flowmvi.plugins.reduce +import pro.respawn.flowmvi.plugins.reducePlugin -internal inline fun optimizedStore( - scope: CoroutineScope, -) = store(BenchmarkState(), scope) { +internal fun StoreBuilder<*, *, *>.config() { configure { logger = null debuggable = false @@ -23,11 +23,24 @@ internal inline fun optimizedStore( onOverflow = BufferOverflow.DROP_OLDEST intentCapacity = Channel.RENDEZVOUS } - reduce { - when (it) { - is Increment -> updateStateImmediate { - copy(counter = counter + 1) - } +} + +private val reduce = reducePlugin { + when (it) { + is Increment -> updateStateImmediate { + copy(counter = counter + 1) } } } + +internal inline fun optimizedStore( + scope: CoroutineScope, +) = store(BenchmarkState(), scope) { + config() + install(reduce) +} + +internal inline fun optimizedStore() = store(BenchmarkState()) { + config() + install(reduce) +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt index fdd50342..b15b90ff 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt @@ -10,12 +10,14 @@ import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads import pro.respawn.flowmvi.api.Store import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState import pro.respawn.flowmvi.dsl.collect +@Threads(Threads.MAX) @Suppress("unused") @State(Scope.Benchmark) internal class ParallelFMVIBenchmark { diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt index be5d8f46..558fedc9 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt @@ -6,9 +6,11 @@ import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +@Threads(Threads.MAX) @Suppress("unused") @State(Scope.Benchmark) internal class TraditionalMVIBenchmark { From 964c419f55a161e536839a32b6b153c5185e8ef5 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 5 Dec 2024 13:12:13 +0100 Subject: [PATCH 08/36] fix: fix benchmarks --- benchmarks/build.gradle.kts | 2 +- .../flowmvi/benchmarks/BenchmarkDefaults.kt | 2 +- .../setup/atomic/AtomicFMVIBenchmark.kt | 29 +++++++++++ .../AtomicStore.kt} | 6 ++- .../ChannelTraditionalMVIBenchmark.kt | 25 ++-------- .../setup/channelbased/TraditionalMVIStore.kt | 14 ++++-- .../setup/fluxo/FluxoIntentBenchmark.kt | 16 +------ .../setup/fluxo/FluxoStartStopBenchmark.kt | 1 + .../setup/optimized/OptimizedFMVIBenchmark.kt | 29 ++--------- .../setup/optimized/OptimizedStore.kt | 2 +- .../setup/parallel/ParallelFMVIBenchmark.kt | 48 ------------------- .../traditional/TraditionalMVIBenchmark.kt | 9 +--- 12 files changed, 61 insertions(+), 122 deletions(-) create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicFMVIBenchmark.kt rename benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/{parallel/ParallelStore.kt => atomic/AtomicStore.kt} (85%) delete mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 73dd8521..059d8d73 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -51,7 +51,7 @@ benchmark { iterationTime = 100 iterationTimeUnit = "ms" outputTimeUnit = "ms" - mode = "thrpt" // "thrpt" - throughput, "avgt" - average + mode = "avgt" // "thrpt" - throughput, "avgt" - average reportFormat = "text" // advanced("nativeGCAfterIteration", true) // advanced("jvmForks", "definedByJmh") diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt index 6f516ad1..02618d75 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt @@ -2,5 +2,5 @@ package pro.respawn.flowmvi.benchmarks internal object BenchmarkDefaults { - const val intentsPerIteration = 1000 + const val intentsPerIteration = 10000 } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicFMVIBenchmark.kt new file mode 100644 index 00000000..5c92deb8 --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicFMVIBenchmark.kt @@ -0,0 +1,29 @@ +package pro.respawn.flowmvi.benchmarks.setup.atomic + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads +import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +import pro.respawn.flowmvi.dsl.collect + +@Threads(Threads.MAX) +@Suppress("unused") +@State(Scope.Benchmark) +internal class AtomicFMVIBenchmark { + + @Benchmark + fun benchmark() = runBlocking { + val store = atomicParallelStore(this) + repeat(BenchmarkDefaults.intentsPerIteration) { + store.emit(BenchmarkIntent.Increment) + } + store.collect { + states.first { state -> state.counter == BenchmarkDefaults.intentsPerIteration } + } + store.closeAndWait() + } +} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt similarity index 85% rename from benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt rename to benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt index 6aa5cf93..13af4110 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt @@ -1,4 +1,4 @@ -package pro.respawn.flowmvi.benchmarks.setup.parallel +package pro.respawn.flowmvi.benchmarks.setup.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow @@ -9,6 +9,7 @@ import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.plugins.deinit import pro.respawn.flowmvi.plugins.reduce private fun StoreBuilder<*, *, *>.config() = configure { @@ -16,7 +17,7 @@ private fun StoreBuilder<*, *, *>.config() = configure { debuggable = false actionShareBehavior = ActionShareBehavior.Disabled atomicStateUpdates = true - parallelIntents = true + parallelIntents = false verifyPlugins = false onOverflow = BufferOverflow.SUSPEND intentCapacity = Channel.UNLIMITED @@ -31,4 +32,5 @@ internal fun atomicParallelStore( is Increment -> updateState { copy(counter = counter + 1) } } } + deinit { e -> updateStateImmediate { BenchmarkState() } } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt index 5884e3ac..0ac9da44 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt @@ -1,41 +1,26 @@ package pro.respawn.flowmvi.benchmarks.setup.channelbased -import kotlinx.benchmark.TearDown -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope -import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent +@Threads(Threads.MAX) @Suppress("unused") @State(Scope.Benchmark) internal class ChannelTraditionalMVIBenchmark { - lateinit var store: ChannelBasedTraditionalStore - lateinit var scope: CoroutineScope - - @Setup - fun setup() { - scope = CoroutineScope(Dispatchers.Unconfined) - store = ChannelBasedTraditionalStore(scope) - } - @Benchmark fun benchmark() = runBlocking { + val store = ChannelBasedTraditionalStore(this) repeat(BenchmarkDefaults.intentsPerIteration) { store.onIntent(BenchmarkIntent.Increment) } - store.state.first { state -> state.counter >= BenchmarkDefaults.intentsPerIteration } - } - - @TearDown - fun teardown() = runBlocking { - scope.cancel() + store.state.first { state -> state.counter == BenchmarkDefaults.intentsPerIteration } + store.close() } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt index f32219ea..29da541d 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt @@ -1,6 +1,9 @@ package pro.respawn.flowmvi.benchmarks.setup.channelbased import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -13,19 +16,24 @@ internal class ChannelBasedTraditionalStore(scope: CoroutineScope) { private val _state = MutableStateFlow(BenchmarkState()) val state = _state.asStateFlow() - val intents = Channel() + val intents = Channel(capacity = Channel.UNLIMITED, onBufferOverflow = BufferOverflow.SUSPEND) + private var job: Job? = null init { - scope.launch { + job = scope.launch { for (intent in intents) reduce(intent) } } - fun onIntent(intent: BenchmarkIntent) = intents.trySend(intent) + suspend fun onIntent(intent: BenchmarkIntent) = intents.send(intent) private fun reduce(intent: BenchmarkIntent) = when (intent) { is BenchmarkIntent.Increment -> _state.update { state -> state.copy(counter = state.counter + 1) } } + + suspend fun close() { + job!!.cancelAndJoin() + } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt index e084421a..0ad4b87a 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt @@ -1,40 +1,28 @@ package pro.respawn.flowmvi.benchmarks.setup.fluxo -import kotlinx.benchmark.TearDown import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kt.fluxo.core.closeAndWait import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope -import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State import org.openjdk.jmh.annotations.Threads import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent -import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState @Threads(Threads.MAX) @Suppress("unused") @State(Scope.Benchmark) internal class FluxoIntentBenchmark { - lateinit var store: kt.fluxo.core.Store - - @Setup - fun setup() = runBlocking { - store = fluxoStore() - } @Benchmark fun benchmark() = runBlocking { + val store = fluxoStore() repeat(BenchmarkDefaults.intentsPerIteration) { store.send(BenchmarkIntent.Increment) } - store.first { state -> state.counter >= BenchmarkDefaults.intentsPerIteration } - } - - @TearDown - fun teardown() = runBlocking { + store.first { state -> state.counter == BenchmarkDefaults.intentsPerIteration } store.closeAndWait() } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt index b720345a..9091b19f 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt @@ -9,6 +9,7 @@ import org.openjdk.jmh.annotations.State import org.openjdk.jmh.annotations.Threads @Threads(Threads.MAX) +@Suppress("unused") @State(Scope.Benchmark) class FluxoStartStopBenchmark { diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt index 907300d5..2523a7d6 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt @@ -1,20 +1,13 @@ package pro.respawn.flowmvi.benchmarks.setup.optimized -import kotlinx.benchmark.TearDown -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope -import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State import org.openjdk.jmh.annotations.Threads -import pro.respawn.flowmvi.api.Store import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent -import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState import pro.respawn.flowmvi.dsl.collect @Threads(Threads.MAX) @@ -22,27 +15,15 @@ import pro.respawn.flowmvi.dsl.collect @State(Scope.Benchmark) internal class OptimizedFMVIBenchmark { - lateinit var store: Store - lateinit var scope: CoroutineScope - - @Setup - fun setup() = runBlocking { - scope = CoroutineScope(Dispatchers.Unconfined) - store = optimizedStore(scope) - store.awaitStartup() - } - @Benchmark fun benchmark() = runBlocking { + val store = optimizedStore(this) repeat(BenchmarkDefaults.intentsPerIteration) { - store.intent(BenchmarkIntent.Increment) + store.emit(BenchmarkIntent.Increment) + } + store.collect { + states.first { it.counter == BenchmarkDefaults.intentsPerIteration } } - store.collect { states.first { it.counter >= BenchmarkDefaults.intentsPerIteration } } - } - - @TearDown - fun teardown() = runBlocking { - scope.cancel() store.closeAndWait() } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt index 8121eb50..6eae6321 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt @@ -20,7 +20,7 @@ internal fun StoreBuilder<*, *, *>.config() { atomicStateUpdates = false parallelIntents = false verifyPlugins = false - onOverflow = BufferOverflow.DROP_OLDEST + onOverflow = BufferOverflow.SUSPEND intentCapacity = Channel.RENDEZVOUS } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt deleted file mode 100644 index b15b90ff..00000000 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/parallel/ParallelFMVIBenchmark.kt +++ /dev/null @@ -1,48 +0,0 @@ -package pro.respawn.flowmvi.benchmarks.setup.parallel - -import kotlinx.benchmark.TearDown -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.openjdk.jmh.annotations.Benchmark -import org.openjdk.jmh.annotations.Scope -import org.openjdk.jmh.annotations.Setup -import org.openjdk.jmh.annotations.State -import org.openjdk.jmh.annotations.Threads -import pro.respawn.flowmvi.api.Store -import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults -import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent -import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState -import pro.respawn.flowmvi.dsl.collect - -@Threads(Threads.MAX) -@Suppress("unused") -@State(Scope.Benchmark) -internal class ParallelFMVIBenchmark { - - lateinit var store: Store - lateinit var scope: CoroutineScope - - @Setup - fun setup() = runBlocking { - scope = CoroutineScope(Dispatchers.Unconfined) - store = atomicParallelStore(scope) - store.awaitStartup() - } - - @Benchmark - fun benchmark() = runBlocking { - repeat(BenchmarkDefaults.intentsPerIteration) { - store.intent(BenchmarkIntent.Increment) - } - store.collect { states.first { it.counter >= BenchmarkDefaults.intentsPerIteration } } - } - - @TearDown - fun teardown() = runBlocking { - scope.cancel() - store.closeAndWait() - } -} diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt index 558fedc9..9de9347e 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope -import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State import org.openjdk.jmh.annotations.Threads import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults @@ -15,15 +14,9 @@ import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent @State(Scope.Benchmark) internal class TraditionalMVIBenchmark { - var store = TraditionalMVIStore() - - @Setup - fun setup() { - store = TraditionalMVIStore() - } - @Benchmark fun benchmark() = runBlocking { + val store = TraditionalMVIStore() repeat(BenchmarkDefaults.intentsPerIteration) { store.onIntent(BenchmarkIntent.Increment) } From 41d00193fcc1a2da7b04a3fc546c7e13acc73083 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 5 Dec 2024 13:20:19 +0100 Subject: [PATCH 09/36] api: Improve performance of updateStateImmediate --- .../benchmarks/setup/atomic/AtomicStore.kt | 2 - .../setup/optimized/OptimizedStore.kt | 1 + .../kotlin/pro/respawn/flowmvi/StoreImpl.kt | 22 +++++++--- .../flowmvi/api/ImmediateStateReceiver.kt | 16 +------- .../pro/respawn/flowmvi/api/StateReceiver.kt | 9 ---- .../pro/respawn/flowmvi/dsl/StateDsl.kt | 41 +++++++++++++++++-- .../respawn/flowmvi/modules/PipelineModule.kt | 13 +++--- .../respawn/flowmvi/modules/StateModule.kt | 33 ++++++++------- .../flowmvi/test/store/StoreStatesTest.kt | 1 + .../test/plugin/TestPipelineContext.kt | 15 +++---- 10 files changed, 86 insertions(+), 67 deletions(-) diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt index 13af4110..4930c2ab 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt @@ -9,7 +9,6 @@ import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.store -import pro.respawn.flowmvi.plugins.deinit import pro.respawn.flowmvi.plugins.reduce private fun StoreBuilder<*, *, *>.config() = configure { @@ -32,5 +31,4 @@ internal fun atomicParallelStore( is Increment -> updateState { copy(counter = counter + 1) } } } - deinit { e -> updateStateImmediate { BenchmarkState() } } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt index 6eae6321..914e743b 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt @@ -9,6 +9,7 @@ import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.plugins.reduce import pro.respawn.flowmvi.plugins.reducePlugin diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt index 0953be55..7543009e 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt @@ -9,11 +9,13 @@ import pro.respawn.flowmvi.annotation.NotIntendedForInheritance import pro.respawn.flowmvi.api.ActionProvider import pro.respawn.flowmvi.api.ActionReceiver import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.api.ImmediateStateReceiver import pro.respawn.flowmvi.api.IntentReceiver import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.Provider +import pro.respawn.flowmvi.api.StateProvider import pro.respawn.flowmvi.api.Store import pro.respawn.flowmvi.api.StoreConfiguration import pro.respawn.flowmvi.api.StorePlugin @@ -32,16 +34,20 @@ import pro.respawn.flowmvi.modules.launchPipeline import pro.respawn.flowmvi.modules.observeSubscribers import pro.respawn.flowmvi.modules.recoverModule import pro.respawn.flowmvi.modules.restartableLifecycle -import pro.respawn.flowmvi.modules.stateModule import pro.respawn.flowmvi.modules.subscriptionModule +import kotlin.getValue @OptIn(NotIntendedForInheritance::class) internal class StoreImpl( override val config: StoreConfiguration, private val plugin: PluginInstance, + private val stateModule: StateModule = StateModule( + config.initial, + config.atomicStateUpdates, + plugin.onState + ), recover: RecoverModule = recoverModule(plugin), - subs: SubscriptionModule = subscriptionModule(), - states: StateModule = stateModule(config.initial, config.atomicStateUpdates), + subs: SubscriptionModule = subscriptionModule() ) : Store, Provider, ShutdownContext, @@ -51,8 +57,9 @@ internal class StoreImpl( RestartableLifecycle by restartableLifecycle(), StorePlugin by plugin, RecoverModule by recover, - SubscriptionModule by subs, - StateModule by states { + StateProvider by stateModule, + ImmediateStateReceiver by stateModule, + SubscriptionModule by subs { private val intents = intentModule( parallel = config.parallelIntents, @@ -66,15 +73,18 @@ internal class StoreImpl( onUndeliveredAction = plugin.onUndeliveredAction?.let { { action -> it(this, action) } } ) + @DelicateStoreApi + override val state: S by stateModule::state override suspend fun emit(intent: I) = intents.emit(intent) + override fun intent(intent: I) = intents.intent(intent) // region pipeline override fun start(scope: CoroutineScope) = launchPipeline( parent = scope, storeConfig = config, + states = stateModule, onAction = { action -> onAction(action)?.let { _actions.action(it) } }, - onTransformState = { transform -> this@StoreImpl.updateState { onState(this, transform()) ?: this } }, onStop = { e -> close().also { plugin.onStop?.invoke(this, e) } }, onStart = pipeline@{ lifecycle -> beginStartup(lifecycle) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt index fab14fc9..81ed37a8 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt @@ -6,22 +6,8 @@ package pro.respawn.flowmvi.api */ public interface ImmediateStateReceiver { - /** - * A function that obtains current state and updates it atomically (in the thread context), and non-atomically in - * the coroutine context, which means it can cause races when you want to update states in parallel. - * - * This function is performant, but **ignores ALL plugins** and - * **does not perform a serializable state transaction** - * - * It should only be used for the state updates that demand the highest performance and happen very often. - * If [StoreConfiguration.atomicStateUpdates] is `false`, then this function is the same - * as [StateReceiver.updateState] - * - * @see StateReceiver.updateState - * @see StateReceiver.withState - */ @FlowMVIDSL - public fun updateStateImmediate(block: S.() -> S) + public fun compareAndSet(old: S, new: S): Boolean /** * Obtain the current value of state in an unsafe manner. diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt index 516c9f5d..5d06fa2d 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt @@ -44,13 +44,4 @@ public interface StateReceiver : ImmediateStateReceiver { */ @FlowMVIDSL public suspend fun withState(block: suspend S.() -> Unit) - - // region deprecated - - @FlowMVIDSL - @Suppress("UndocumentedPublicFunction") - @Deprecated("renamed to updateStateImmediate()", ReplaceWith("updateStateImmediate(block)")) - public fun useState(block: S.() -> S): Unit = updateStateImmediate(block) - - // endregion } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt index eea2aa20..4dcb5a8b 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt @@ -1,12 +1,45 @@ package pro.respawn.flowmvi.dsl +import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.ImmediateStateReceiver import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.StateReceiver +import pro.respawn.flowmvi.api.StoreConfiguration import pro.respawn.flowmvi.exceptions.InvalidStateException import pro.respawn.flowmvi.util.typed import pro.respawn.flowmvi.util.withType +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.jvm.JvmName + +/** + * A function that obtains current state and updates it atomically (in the thread context), and non-atomically in + * the coroutine context, which means it can cause races when you want to update states in parallel. + * + * This function is performant, but **ignores ALL plugins** and + * **does not perform a serializable state transaction** + * + * It should only be used for the state updates that demand the highest performance and happen very often. + * If [StoreConfiguration.atomicStateUpdates] is `false`, then this function is the same + * as [StateReceiver.updateState] + * + * @see StateReceiver.updateState + * @see StateReceiver.withState + */ +@OptIn(DelicateStoreApi::class) +@FlowMVIDSL +public inline fun ImmediateStateReceiver.updateStateImmediate( + @BuilderInference transform: S.() -> S +) { + contract { + callsInPlace(transform, InvocationKind.AT_LEAST_ONCE) + } + while (true) { + if (compareAndSet(state, transform(state))) return + } +} /** * A typed overload of [StateReceiver.withState]. @@ -25,15 +58,15 @@ public suspend inline fun StateReceiver.updateS ) = updateState { withType { transform() } } /** - * A typed overload of [StateReceiver.updateStateImmediate]. + * A typed overload of [updateStateImmediate]. * * @see StateReceiver.withState - * @see StateReceiver.updateStateImmediate * @see StateReceiver.updateState */ @FlowMVIDSL -public inline fun StateReceiver.updateStateImmediate( - @BuilderInference crossinline transform: T.() -> S +@JvmName("updateStateImmediateTyped") +public inline fun ImmediateStateReceiver.updateStateImmediate( + @BuilderInference transform: T.() -> S ) = updateStateImmediate { withType { transform() } } /** diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt index 0a33879f..5963e152 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt @@ -11,12 +11,12 @@ import kotlinx.coroutines.launch import pro.respawn.flowmvi.annotation.NotIntendedForInheritance import pro.respawn.flowmvi.api.ActionReceiver import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.api.ImmediateStateReceiver import pro.respawn.flowmvi.api.IntentReceiver import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext -import pro.respawn.flowmvi.api.StateReceiver import pro.respawn.flowmvi.api.StoreConfiguration import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle @@ -34,11 +34,11 @@ import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle internal inline fun T.launchPipeline( parent: CoroutineScope, storeConfig: StoreConfiguration, + states: StateModule, crossinline onStop: (e: Exception?) -> Unit, crossinline onAction: suspend PipelineContext.(action: A) -> Unit, - crossinline onTransformState: suspend PipelineContext.(transform: suspend S.() -> S) -> Unit, onStart: PipelineContext.(lifecycle: StoreLifecycleModule) -> Unit, -): StoreLifecycle where T : IntentReceiver, T : StateReceiver, T : RecoverModule { +): StoreLifecycle where T : IntentReceiver, T : RecoverModule { val job = SupervisorJob(parent.coroutineContext[Job]).apply { invokeOnCompletion { when (it) { @@ -50,7 +50,7 @@ internal inline fun T.launchPipe } return object : IntentReceiver by this, - StateReceiver by this, + ImmediateStateReceiver by states, PipelineContext, StoreLifecycleModule by storeLifecycle(job), ActionReceiver { @@ -69,7 +69,10 @@ internal inline fun T.launchPipe override fun toString(): String = "${storeConfig.name.orEmpty()}PipelineContext" - override suspend fun updateState(transform: suspend S.() -> S) = catch { onTransformState(transform) } + override suspend fun updateState(transform: suspend S.() -> S) = catch { states.run { useState(transform) } } + + override suspend fun withState(block: suspend S.() -> Unit) = catch { states.withState(block) } + override suspend fun action(action: A) = catch { onAction(action) } override fun send(action: A) { launch { action(action) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt index 494d9fa4..8980c7ab 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt @@ -1,44 +1,43 @@ -@file:Suppress("OVERRIDE_BY_INLINE") - package pro.respawn.flowmvi.modules import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.api.ImmediateStateReceiver +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.StateProvider -import pro.respawn.flowmvi.api.StateReceiver +import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.util.withReentrantLock -internal fun stateModule( +internal class StateModule( initial: S, atomic: Boolean, -): StateModule = StateModuleImpl(initial, if (atomic) Mutex() else null) - -internal interface StateModule : StateReceiver, StateProvider - -private class StateModuleImpl( - initial: S, - private val mutex: Mutex?, -) : StateModule { + private val transform: (suspend PipelineContext.(old: S, new: S) -> S?)? +) : StateProvider, ImmediateStateReceiver { @Suppress("VariableNaming") private val _states = MutableStateFlow(initial) override val states: StateFlow = _states.asStateFlow() + private val mutex = if (atomic) Mutex() else null @DelicateStoreApi override val state: S by states::value - override inline fun updateStateImmediate(block: S.() -> S) = _states.update(block) + override fun compareAndSet(expect: S, new: S) = _states.compareAndSet(expect, new) - override suspend inline fun withState( + suspend inline fun withState( crossinline block: suspend S.() -> Unit ) = mutex.withReentrantLock { block(states.value) } - override suspend inline fun updateState( + suspend inline fun PipelineContext.useState( crossinline transform: suspend S.() -> S - ) = mutex.withReentrantLock { _states.update { transform(it) } } + ) = mutex.withReentrantLock block@{ + val delegate = this@StateModule.transform ?: return@block updateStateImmediate { transform() } + updateStateImmediate { delegate(this, transform()) ?: this } + } } diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt index beddd65a..6abde0c6 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.launch import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.intent import pro.respawn.flowmvi.dsl.send +import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.util.TestAction import pro.respawn.flowmvi.util.TestState diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt index e3f1c74a..acd601f8 100644 --- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt @@ -15,6 +15,7 @@ import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.StoreConfiguration import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle +import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.test.TestStoreLifecycle import pro.respawn.flowmvi.test.ensureStarted @@ -26,8 +27,10 @@ internal class TestPipelineContext @ override val coroutineContext by config::coroutineContext - override var state: S by atomic(config.initial) - private set + private var _state = atomic(config.initial) + override val state by _state::value + + override fun compareAndSet(old: S, new: S): Boolean = _state.compareAndSet(old, new) @DelicateStoreApi override fun send(action: A) { @@ -51,17 +54,11 @@ internal class TestPipelineContext @ override suspend fun updateState(transform: suspend S.() -> S) = with(plugin) { ensureStarted() - onState(state, state.transform())?.also { state = it } - Unit + updateStateImmediate { onState(state, state.transform()) ?: this } } override suspend fun withState(block: suspend S.() -> Unit) { ensureStarted() block(state) } - - override fun updateStateImmediate(block: S.() -> S) { - ensureStarted() - state = block(state) - } } From 6996c65102d332c55459d4a3dd2a2adc045a18b7 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 5 Dec 2024 14:04:04 +0100 Subject: [PATCH 10/36] fix: remove suspend channel calls due to perf overhead --- benchmarks/build.gradle.kts | 4 ++-- .../benchmarks/setup/channelbased/TraditionalMVIStore.kt | 2 +- .../flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt | 1 - .../benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt | 6 ++---- .../flowmvi/benchmarks/setup/optimized/OptimizedStore.kt | 2 +- 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 059d8d73..85fd4b94 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -47,10 +47,10 @@ benchmark { configurations { named("main") { iterations = 100 - warmups = 10 + warmups = 20 iterationTime = 100 iterationTimeUnit = "ms" - outputTimeUnit = "ms" + outputTimeUnit = "us" mode = "avgt" // "thrpt" - throughput, "avgt" - average reportFormat = "text" // advanced("nativeGCAfterIteration", true) diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt index 29da541d..8b573082 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/TraditionalMVIStore.kt @@ -25,7 +25,7 @@ internal class ChannelBasedTraditionalStore(scope: CoroutineScope) { } } - suspend fun onIntent(intent: BenchmarkIntent) = intents.send(intent) + fun onIntent(intent: BenchmarkIntent) = intents.trySend(intent) private fun reduce(intent: BenchmarkIntent) = when (intent) { is BenchmarkIntent.Increment -> _state.update { state -> diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt index 0ad4b87a..66a0f41c 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt @@ -15,7 +15,6 @@ import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent @State(Scope.Benchmark) internal class FluxoIntentBenchmark { - @Benchmark fun benchmark() = runBlocking { val store = fluxoStore() diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt index 2523a7d6..1e2b4649 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt @@ -19,11 +19,9 @@ internal class OptimizedFMVIBenchmark { fun benchmark() = runBlocking { val store = optimizedStore(this) repeat(BenchmarkDefaults.intentsPerIteration) { - store.emit(BenchmarkIntent.Increment) - } - store.collect { - states.first { it.counter == BenchmarkDefaults.intentsPerIteration } + store.intent(BenchmarkIntent.Increment) } + store.collect { states.first { it.counter == BenchmarkDefaults.intentsPerIteration } } store.closeAndWait() } } diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt index 914e743b..5dfdebc5 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt @@ -22,7 +22,7 @@ internal fun StoreBuilder<*, *, *>.config() { parallelIntents = false verifyPlugins = false onOverflow = BufferOverflow.SUSPEND - intentCapacity = Channel.RENDEZVOUS + intentCapacity = Channel.UNLIMITED } } From 2763c3ec785efa4c51ccb0101c2801548f084af5 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 5 Dec 2024 21:41:23 +0100 Subject: [PATCH 11/36] feat: Optimize intent reduction --- .../kotlin/pro/respawn/flowmvi/StoreImpl.kt | 56 ++++------ .../respawn/flowmvi/modules/IntentModule.kt | 105 ++++++++++++------ .../flowmvi/modules/SubscriptionModule.kt | 3 + .../flowmvi/test/store/StoreStatesTest.kt | 16 +-- 4 files changed, 99 insertions(+), 81 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt index 7543009e..93ff251b 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt @@ -22,7 +22,6 @@ import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.api.context.ShutdownContext import pro.respawn.flowmvi.exceptions.NonSuspendingSubscriberException import pro.respawn.flowmvi.exceptions.SubscribeBeforeStartException -import pro.respawn.flowmvi.exceptions.UnhandledIntentException import pro.respawn.flowmvi.impl.plugin.PluginInstance import pro.respawn.flowmvi.modules.RecoverModule import pro.respawn.flowmvi.modules.RestartableLifecycle @@ -32,12 +31,12 @@ import pro.respawn.flowmvi.modules.actionModule import pro.respawn.flowmvi.modules.intentModule import pro.respawn.flowmvi.modules.launchPipeline import pro.respawn.flowmvi.modules.observeSubscribers +import pro.respawn.flowmvi.modules.observesSubscribers import pro.respawn.flowmvi.modules.recoverModule import pro.respawn.flowmvi.modules.restartableLifecycle import pro.respawn.flowmvi.modules.subscriptionModule -import kotlin.getValue -@OptIn(NotIntendedForInheritance::class) +@OptIn(NotIntendedForInheritance::class, DelicateStoreApi::class) internal class StoreImpl( override val config: StoreConfiguration, private val plugin: PluginInstance, @@ -47,7 +46,6 @@ internal class StoreImpl( plugin.onState ), recover: RecoverModule = recoverModule(plugin), - subs: SubscriptionModule = subscriptionModule() ) : Store, Provider, ShutdownContext, @@ -55,16 +53,15 @@ internal class StoreImpl( ActionProvider, ActionReceiver, RestartableLifecycle by restartableLifecycle(), + SubscriptionModule by subscriptionModule(), StorePlugin by plugin, RecoverModule by recover, StateProvider by stateModule, - ImmediateStateReceiver by stateModule, - SubscriptionModule by subs { + ImmediateStateReceiver by stateModule { - private val intents = intentModule( - parallel = config.parallelIntents, - capacity = config.intentCapacity, - overflow = config.onOverflow, + private val intents = intentModule( + config = config, + onIntent = plugin.onIntent?.let { onIntent -> { intent -> catch { onIntent(this, intent) } } }, onUndeliveredIntent = plugin.onUndeliveredIntent?.let { { intent -> it(this, intent) } }, ) @@ -73,12 +70,6 @@ internal class StoreImpl( onUndeliveredAction = plugin.onUndeliveredAction?.let { { action -> it(this, action) } } ) - @DelicateStoreApi - override val state: S by stateModule::state - override suspend fun emit(intent: I) = intents.emit(intent) - - override fun intent(intent: I) = intents.intent(intent) - // region pipeline override fun start(scope: CoroutineScope) = launchPipeline( parent = scope, @@ -88,26 +79,16 @@ internal class StoreImpl( onStop = { e -> close().also { plugin.onStop?.invoke(this, e) } }, onStart = pipeline@{ lifecycle -> beginStartup(lifecycle) - val startup = launch { - if (plugin.onStart != null) catch { onStart() } - lifecycle.completeStartup() - } - if (plugin.onSubscribe != null || plugin.onUnsubscribe != null) launch { - startup.join() - // catch exceptions to not let this job fail - observeSubscribers( - onSubscribe = { catch { onSubscribe(it) } }, - onUnsubscribe = { catch { onUnsubscribe(it) } } - ) - } - if (plugin.onIntent != null) launch { - startup.join() - intents.awaitIntents { - catch { - val result = onIntent(it) - if (result != null && config.debuggable) throw UnhandledIntentException(result) - } + launch { + catch { onStart() } + if (plugin.observesSubscribers) launch { + observeSubscribers( + onSubscribe = { catch { onSubscribe(it) } }, + onUnsubscribe = { catch { onUnsubscribe(it) } } + ) } + lifecycle.completeStartup() + intents.run { reduceForever() } } } ) @@ -124,11 +105,12 @@ internal class StoreImpl( } // region contract + override val state: S by stateModule::state override val name by config::name override val actions: Flow by _actions::actions - - @DelicateStoreApi override fun send(action: A) = _actions.send(action) + override suspend fun emit(intent: I) = intents.emit(intent) + override fun intent(intent: I) = intents.intent(intent) override suspend fun action(action: A) = _actions.action(action) override fun hashCode() = name?.hashCode() ?: super.hashCode() override fun toString(): String = name ?: super.toString() diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt index b18741e1..b4235f36 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt @@ -2,72 +2,103 @@ package pro.respawn.flowmvi.modules import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import kotlinx.coroutines.yield import pro.respawn.flowmvi.api.IntentReceiver +import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StoreConfiguration +import pro.respawn.flowmvi.exceptions.UnhandledIntentException -internal interface IntentModule : IntentReceiver { +internal interface IntentModule : IntentReceiver { - suspend fun awaitIntents(onIntent: suspend (intent: I) -> Unit) + suspend fun PipelineContext.reduceForever() } -internal fun intentModule( - parallel: Boolean, - capacity: Int, - overflow: BufferOverflow, +@Suppress("UNCHECKED_CAST") +internal fun intentModule( + config: StoreConfiguration, onUndeliveredIntent: ((intent: I) -> Unit)?, -): IntentModule = when { - !parallel -> SequentialChannelIntentModule(capacity, overflow, onUndeliveredIntent) - else -> ParallelChannelIntentModule(capacity, overflow, onUndeliveredIntent) + onIntent: (suspend PipelineContext.(intent: I) -> I?)?, +): IntentModule = when { + onIntent == null -> NoOpIntentModule(config.debuggable) + !config.parallelIntents -> SequentialChannelIntentModule( + capacity = config.intentCapacity, + overflow = config.onOverflow, + onUndeliveredIntent = onUndeliveredIntent, + onIntent = onIntent, + debuggable = config.debuggable + ) + else -> ParallelChannelIntentModule( + capacity = config.intentCapacity, + overflow = config.onOverflow, + onUndeliveredIntent = onUndeliveredIntent, + onIntent = onIntent, + debuggable = config.debuggable + ) +} + +private class NoOpIntentModule( + private val debuggable: Boolean +) : IntentModule { + + override suspend fun PipelineContext.reduceForever() = Unit + override suspend fun emit(intent: I) = intent(intent) + override fun intent(intent: I) = unhandled(intent, debuggable) } -private abstract class ChannelIntentModule( +private abstract class ChannelIntentModule( capacity: Int, overflow: BufferOverflow, - onUndeliveredIntent: ((intent: I) -> Unit)?, -) : IntentModule { + private val onUndeliveredIntent: ((intent: I) -> Unit)?, + private val onIntent: suspend PipelineContext.(intent: I) -> I?, + private val debuggable: Boolean, +) : IntentModule { val intents = Channel(capacity, overflow, onUndeliveredIntent) - override suspend fun emit(intent: I) = intents.send(intent) + override fun intent(intent: I) { intents.trySend(intent) } + + abstract suspend fun PipelineContext.dispatch(intent: I) + + suspend inline fun PipelineContext.reduce(intent: I) { + val unhandled = onIntent(intent) ?: return + onUndeliveredIntent?.invoke(unhandled) ?: unhandled(unhandled, debuggable) + } + + override suspend fun PipelineContext.reduceForever() { + while (isActive) dispatch(intents.receive()) + } } -private class SequentialChannelIntentModule( +private class SequentialChannelIntentModule( capacity: Int, overflow: BufferOverflow, onUndeliveredIntent: ((intent: I) -> Unit)?, -) : ChannelIntentModule(capacity, overflow, onUndeliveredIntent) { - - override suspend fun awaitIntents(onIntent: suspend (intent: I) -> Unit) = coroutineScope { - // must always suspend the current scope to wait for intents - for (intent in intents) { - onIntent(intent) - yield() // TODO: Accounts for 50% performance loss, way to get rid of? Why needed? - } - } + onIntent: suspend PipelineContext.(intent: I) -> I?, + debuggable: Boolean, +) : ChannelIntentModule(capacity, overflow, onUndeliveredIntent, onIntent, debuggable) { + + override suspend fun PipelineContext.dispatch(intent: I) = reduce(intent) } -private class ParallelChannelIntentModule( +private class ParallelChannelIntentModule( capacity: Int, overflow: BufferOverflow, onUndeliveredIntent: ((intent: I) -> Unit)?, -) : IntentModule { - - private val intents = Channel(capacity, overflow, onUndeliveredIntent) + onIntent: suspend PipelineContext.(intent: I) -> I?, + debuggable: Boolean, +) : ChannelIntentModule(capacity, overflow, onUndeliveredIntent, onIntent, debuggable) { - override suspend fun emit(intent: I) = intents.send(intent) - override fun intent(intent: I) { - intents.trySend(intent) + override suspend fun PipelineContext.dispatch(intent: I) { + launch { reduce(intent) } } +} - // TODO: We should let the user limit parallelism here to avoid starvation - override suspend fun awaitIntents(onIntent: suspend (intent: I) -> Unit) = coroutineScope { - // must always suspend the current scope to wait for intents - for (intent in intents) launch { onIntent(intent) } - } +private inline fun unhandled(intent: I, debuggable: Boolean) { + if (debuggable) throw UnhandledIntentException(intent) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt index df54f5df..ba18dc70 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt @@ -2,6 +2,7 @@ package pro.respawn.flowmvi.modules import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import pro.respawn.flowmvi.impl.plugin.PluginInstance import pro.respawn.flowmvi.util.withPrevious internal fun subscriptionModule(): SubscriptionModule = SubscriptionModuleImpl() @@ -37,3 +38,5 @@ internal suspend inline fun SubscriptionModule.observeSubscribers( new < previous -> onUnsubscribe(new) } } + +internal inline val PluginInstance<*, *, *>.observesSubscribers get() = onSubscribe != null || onUnsubscribe != null diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt index 6abde0c6..b4095232 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt @@ -4,7 +4,6 @@ import app.cash.turbine.test import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.launch import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.intent import pro.respawn.flowmvi.dsl.send @@ -23,18 +22,21 @@ class StoreStatesTest : FreeSpec({ beforeEach { timeTravel.reset() } "given lambdaIntent store" - { - val store = testStore(timeTravel) + val store = testStore(timeTravel) { + configure { + parallelIntents = true + } + } "and intent that blocks state" - { val blockingIntent = LambdaIntent { - launch { - updateState { - awaitCancellation() - } + updateState { + awaitCancellation() } } "then state is never updated by another intent" { store.subscribeAndTest { emit(blockingIntent) + idle() intent { updateState { TestState.SomeData(1) @@ -49,7 +51,7 @@ class StoreStatesTest : FreeSpec({ } "then withState is never executed" { store.subscribeAndTest { - send(blockingIntent) + emit(blockingIntent) intent { withState { throw AssertionError("WithState was executed") From 0c2a6f43cd5637e5d7d7f1cc243abdce92a99117 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 5 Dec 2024 21:59:13 +0100 Subject: [PATCH 12/36] feat: optimize recover performance --- .../kotlin/pro/respawn/flowmvi/StoreImpl.kt | 14 ++-- .../respawn/flowmvi/modules/PipelineModule.kt | 17 ++-- .../respawn/flowmvi/modules/RecoverModule.kt | 82 ++++++++++--------- 3 files changed, 61 insertions(+), 52 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt index 93ff251b..d2b419f4 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt @@ -28,11 +28,11 @@ import pro.respawn.flowmvi.modules.RestartableLifecycle import pro.respawn.flowmvi.modules.StateModule import pro.respawn.flowmvi.modules.SubscriptionModule import pro.respawn.flowmvi.modules.actionModule +import pro.respawn.flowmvi.modules.catch import pro.respawn.flowmvi.modules.intentModule import pro.respawn.flowmvi.modules.launchPipeline import pro.respawn.flowmvi.modules.observeSubscribers import pro.respawn.flowmvi.modules.observesSubscribers -import pro.respawn.flowmvi.modules.recoverModule import pro.respawn.flowmvi.modules.restartableLifecycle import pro.respawn.flowmvi.modules.subscriptionModule @@ -40,12 +40,12 @@ import pro.respawn.flowmvi.modules.subscriptionModule internal class StoreImpl( override val config: StoreConfiguration, private val plugin: PluginInstance, + private val recover: RecoverModule = RecoverModule(plugin.onException), private val stateModule: StateModule = StateModule( config.initial, config.atomicStateUpdates, plugin.onState ), - recover: RecoverModule = recoverModule(plugin), ) : Store, Provider, ShutdownContext, @@ -55,13 +55,12 @@ internal class StoreImpl( RestartableLifecycle by restartableLifecycle(), SubscriptionModule by subscriptionModule(), StorePlugin by plugin, - RecoverModule by recover, StateProvider by stateModule, ImmediateStateReceiver by stateModule { private val intents = intentModule( config = config, - onIntent = plugin.onIntent?.let { onIntent -> { intent -> catch { onIntent(this, intent) } } }, + onIntent = plugin.onIntent?.let { onIntent -> { intent -> catch(recover) { onIntent(this, intent) } } }, onUndeliveredIntent = plugin.onUndeliveredIntent?.let { { intent -> it(this, intent) } }, ) @@ -75,16 +74,17 @@ internal class StoreImpl( parent = scope, storeConfig = config, states = stateModule, + recover = recover, onAction = { action -> onAction(action)?.let { _actions.action(it) } }, onStop = { e -> close().also { plugin.onStop?.invoke(this, e) } }, onStart = pipeline@{ lifecycle -> beginStartup(lifecycle) launch { - catch { onStart() } + catch(recover) { onStart() } if (plugin.observesSubscribers) launch { observeSubscribers( - onSubscribe = { catch { onSubscribe(it) } }, - onUnsubscribe = { catch { onUnsubscribe(it) } } + onSubscribe = { catch(recover) { onSubscribe(it) } }, + onUnsubscribe = { catch(recover) { onUnsubscribe(it) } } ) } lifecycle.completeStartup() diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt index 5963e152..ebda27a4 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt @@ -35,10 +35,11 @@ internal inline fun T.launchPipe parent: CoroutineScope, storeConfig: StoreConfiguration, states: StateModule, + recover: RecoverModule, crossinline onStop: (e: Exception?) -> Unit, crossinline onAction: suspend PipelineContext.(action: A) -> Unit, onStart: PipelineContext.(lifecycle: StoreLifecycleModule) -> Unit, -): StoreLifecycle where T : IntentReceiver, T : RecoverModule { +): StoreLifecycle where T : IntentReceiver { val job = SupervisorJob(parent.coroutineContext[Job]).apply { invokeOnCompletion { when (it) { @@ -57,7 +58,7 @@ internal inline fun T.launchPipe override val config = storeConfig override val key = PipelineContext // recoverable should be separate from this key - private val handler = PipelineExceptionHandler() + private val handler = PipelineExceptionHandler(recover) private val pipelineName = CoroutineName(toString()) override val coroutineContext = parent.coroutineContext + @@ -69,11 +70,17 @@ internal inline fun T.launchPipe override fun toString(): String = "${storeConfig.name.orEmpty()}PipelineContext" - override suspend fun updateState(transform: suspend S.() -> S) = catch { states.run { useState(transform) } } + override suspend fun updateState(transform: suspend S.() -> S) { + catch(recover) { states.run { useState(transform) } } + } - override suspend fun withState(block: suspend S.() -> Unit) = catch { states.withState(block) } + override suspend fun withState(block: suspend S.() -> Unit) { + catch(recover) { states.withState(block) } + } - override suspend fun action(action: A) = catch { onAction(action) } + override suspend fun action(action: A) { + catch(recover) { onAction(action) } + } override fun send(action: A) { launch { action(action) } } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt index 582013e4..b6b46085 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt @@ -8,7 +8,6 @@ import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.Store -import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.api.UnrecoverableException import pro.respawn.flowmvi.exceptions.RecursiveRecoverException import pro.respawn.flowmvi.exceptions.UnhandledStoreException @@ -16,51 +15,18 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.coroutineContext -internal fun recoverModule( - delegate: StorePlugin, -) = RecoverModule { e -> - with(delegate) { - if (e is UnrecoverableException) throw e - onException(e)?.let { throw UnhandledStoreException(it) } - } -} - /** * An entity that can [recover] from exceptions happening during its lifecycle. Most often, a [Store] */ -internal fun interface RecoverModule : CoroutineContext.Element { +internal class RecoverModule( + private val handler: (suspend PipelineContext.(e: Exception) -> Exception?)? +) : CoroutineContext.Element { override val key: CoroutineContext.Key<*> get() = RecoverModule - suspend fun PipelineContext.handle(e: Exception) - - /** - * Run [block] catching any exceptions and invoking [recover]. This will add this [RecoverModule] key to the coroutine - * context of the [recover] block. - */ - suspend fun PipelineContext.catch(block: suspend () -> Unit): Unit = try { - block() - } catch (expected: Exception) { - when { - expected is CancellationException || expected is UnrecoverableException -> throw expected - alreadyRecovered() -> throw RecursiveRecoverException(expected) - else -> withContext(this@RecoverModule) { handle(expected) } - } - } - - @Suppress("FunctionName") - fun PipelineContext.PipelineExceptionHandler() = CoroutineExceptionHandler { ctx, e -> - when { - e !is Exception || e is CancellationException -> throw e - e is UnrecoverableException -> throw e.unwrapRecursion() - ctx.alreadyRecovered -> throw e - // add Recoverable to the coroutine context - // and handle the exception asynchronously to allow suspending inside recover - // Do NOT use the "ctx" parameter here, as that coroutine context is already invalid and will not launch - else -> launch(this@RecoverModule) { handle(e) }.invokeOnCompletion { cause -> - if (cause != null && cause !is CancellationException) throw cause - } - } + suspend fun PipelineContext.handle(e: Exception) { + if (handler == null) throw UnhandledStoreException(e) + handler.invoke(this@handle, e)?.let { throw UnhandledStoreException(it) } } companion object : CoroutineContext.Key> @@ -77,3 +43,39 @@ private tailrec fun UnrecoverableException.unwrapRecursion(): Exception = when ( private suspend fun alreadyRecovered() = coroutineContext.alreadyRecovered private val CoroutineContext.alreadyRecovered get() = this[RecoverModule] != null + +/** + * Run [block] catching any exceptions and invoking [recover]. This will add this [RecoverModule] key to the coroutine + * context of the [recover] block. + */ +internal suspend inline fun PipelineContext.catch( + recover: RecoverModule, + block: suspend () -> R +): R? = try { + block() +} catch (expected: Exception) { + when { + expected is CancellationException || expected is UnrecoverableException -> throw expected + alreadyRecovered() -> throw RecursiveRecoverException(expected) + else -> withContext(recover) { + recover.run { handle(expected) } + null + } + } +} + +internal fun PipelineContext.PipelineExceptionHandler( + recover: RecoverModule +) = CoroutineExceptionHandler { ctx, e -> + when { + e !is Exception || e is CancellationException -> throw e + e is UnrecoverableException -> throw e.unwrapRecursion() + ctx.alreadyRecovered -> throw e + // add Recoverable to the coroutine context + // and handle the exception asynchronously to allow suspending inside recover + // Do NOT use the "ctx" parameter here, as that coroutine context is already invalid and will not launch + else -> launch(recover) { recover.run { handle(e) } }.invokeOnCompletion { cause -> + if (cause != null && cause !is CancellationException) throw cause + } + } +} From fee4e92d399e7940b2ba64558eb6f09afcb1b77e Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 09:16:06 +0100 Subject: [PATCH 13/36] chore: bump agp --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9d51f2e..15f111b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ detekt = "1.23.7" dokka = "2.0.0-Beta" essenty = "2.3.0" fragment = "1.8.5" -gradleAndroid = "8.7.2" +gradleAndroid = "8.8.0-rc01" gradleDoctorPlugin = "0.10.0" intellij-ide-plugin = "2.1.0" intellij-idea = "2024.1" From de111b39d8d8bd32dd5fdc5779cad050349f3687 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 09:47:38 +0100 Subject: [PATCH 14/36] api: optimize state property accessor to be inline --- .../pro/respawn/flowmvi/benchmarks/Main.kt | 22 +++++++++++++++++++ .../respawn/flowmvi/compose/dsl/ComposeDsl.kt | 1 + .../kotlin/pro/respawn/flowmvi/StoreImpl.kt | 5 ++++- .../flowmvi/annotation/InternalFlowMVIAPI.kt | 7 +++--- .../flowmvi/api/ImmediateStateReceiver.kt | 14 +++++------- .../pro/respawn/flowmvi/api/ImmutableStore.kt | 20 ++++++----------- .../pro/respawn/flowmvi/api/StateProvider.kt | 16 +------------- .../kotlin/pro/respawn/flowmvi/api/Store.kt | 1 + .../pro/respawn/flowmvi/dsl/StateDsl.kt | 14 ++++++++++++ .../respawn/flowmvi/modules/StateModule.kt | 10 ++++----- .../flowmvi/test/store/StoreStatesTest.kt | 1 + .../respawn/flowmvi/test/StoreTestScope.kt | 7 +++--- .../test/plugin/TestPipelineContext.kt | 14 +++++++----- 13 files changed, 78 insertions(+), 54 deletions(-) create mode 100644 benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt new file mode 100644 index 00000000..6bcc518e --- /dev/null +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt @@ -0,0 +1,22 @@ +package pro.respawn.flowmvi.benchmarks + +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment +import pro.respawn.flowmvi.benchmarks.setup.optimized.optimizedStore + +fun main() = runBlocking { + println(ProcessHandle.current().pid()) + val store = optimizedStore(this) + launch { + while (isActive) { + store.intent(Increment) + yield() + } + } + awaitCancellation() + Unit +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt index 45c3e6b1..aaa1a93f 100644 --- a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt +++ b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt @@ -18,6 +18,7 @@ import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.SubscriberLifecycle import pro.respawn.flowmvi.api.SubscriptionMode +import pro.respawn.flowmvi.dsl.state import pro.respawn.flowmvi.dsl.subscribe import pro.respawn.flowmvi.util.immediateOrDefault import kotlin.jvm.JvmName diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt index d2b419f4..a02ac48a 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt @@ -1,3 +1,5 @@ +@file:OptIn(InternalFlowMVIAPI::class) + package pro.respawn.flowmvi import kotlinx.coroutines.CoroutineScope @@ -5,6 +7,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.annotation.NotIntendedForInheritance import pro.respawn.flowmvi.api.ActionProvider import pro.respawn.flowmvi.api.ActionReceiver @@ -105,8 +108,8 @@ internal class StoreImpl( } // region contract - override val state: S by stateModule::state override val name by config::name + override val states by stateModule::states override val actions: Flow by _actions::actions override fun send(action: A) = _actions.send(action) override suspend fun emit(intent: I) = intents.emit(intent) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt index 3703d7a1..3ff5412d 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt @@ -1,9 +1,10 @@ package pro.respawn.flowmvi.annotation private const val Message = """ -This API is internal to the library. -Do not use this API directly as public API already exists for the same thing. -If you have the use-case for this api you can't avoid, please submit an issue. +This API is internal to the library and is exposed for performance reasons. +Do NOT use this API directly as public API already exists for the same thing. +If you have the use-case for this api you can't avoid, please submit an issue BEFORE you use it. +This API may be removed, changed or change behavior without prior notice at any moment. """ /** diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt index 81ed37a8..ebae8fe7 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt @@ -1,19 +1,17 @@ package pro.respawn.flowmvi.api +import kotlinx.coroutines.flow.StateFlow +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI + /** * [StateReceiver] version that can only accept immediate state updates. It is recommended to use [StateReceiver] and * its methods if possible. See the method docs for details */ -public interface ImmediateStateReceiver { +public interface ImmediateStateReceiver : StateProvider { @FlowMVIDSL public fun compareAndSet(old: S, new: S): Boolean - /** - * Obtain the current value of state in an unsafe manner. - * It is recommended to always use [StateReceiver.withState] or [StateReceiver.updateState] as obtaining this value can lead - * to data races when the state transaction changes the value of the state previously obtained. - */ - @DelicateStoreApi - public val state: S + @InternalFlowMVIAPI + override val states: StateFlow } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt index c3f7a321..c3607f3a 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt @@ -2,7 +2,8 @@ package pro.respawn.flowmvi.api import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.api.lifecycle.ImmutableStoreLifecycle import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle @@ -10,7 +11,9 @@ import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle * A [Store] that does not allow sending intents. * @see Store */ -public interface ImmutableStore : ImmutableStoreLifecycle { +public interface ImmutableStore : + ImmutableStoreLifecycle, + StateProvider { /** * The name of the store. Used for debugging purposes and when storing multiple stores in a collection. @@ -48,17 +51,8 @@ public interface ImmutableStore.() -> Unit): Job - /** - * Obtain the current state in an unsafe manner. - * This property is not thread-safe and parallel state updates will introduce a race condition when not - * handled properly. - * Such race conditions arise when using multiple data streams such as [Flow]s. - * - * Accessing the state this way will **circumvent ALL plugins**. - */ - @DelicateStoreApi - public val state: S - + @InternalFlowMVIAPI + override val states: StateFlow override fun hashCode(): Int override fun equals(other: Any?): Boolean } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt index c9055c11..ec1bab3b 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt @@ -1,6 +1,5 @@ package pro.respawn.flowmvi.api -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -10,20 +9,7 @@ import kotlinx.coroutines.flow.StateFlow public interface StateProvider { /** - * A flow of states to be handled by the subscriber. + * A flow of states to be rendered by the subscriber. */ public val states: StateFlow - - /** - * Obtain the current state in an unsafe manner. - * This property is not thread-safe and parallel state updates will introduce a race condition when not - * handled properly. - * Such race conditions arise when using multiple data streams such as [Flow]s. - * - * Accessing and modifying the state this way will **circumvent ALL plugins** and will not make state updates atomic. - * - * Consider accessing state via [StateReceiver.withState] or [StateReceiver.updateState] instead. - */ - @DelicateStoreApi - public val state: S } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Store.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Store.kt index 8bf247bb..358d68e4 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Store.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Store.kt @@ -20,4 +20,5 @@ public interface Store : // override with a mutable return type override fun start(scope: CoroutineScope): StoreLifecycle + } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt index 4dcb5a8b..681398b6 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt @@ -5,6 +5,7 @@ import pro.respawn.flowmvi.api.FlowMVIDSL import pro.respawn.flowmvi.api.ImmediateStateReceiver import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StateProvider import pro.respawn.flowmvi.api.StateReceiver import pro.respawn.flowmvi.api.StoreConfiguration import pro.respawn.flowmvi.exceptions.InvalidStateException @@ -14,6 +15,19 @@ import kotlin.contracts.InvocationKind import kotlin.contracts.contract import kotlin.jvm.JvmName +/** + * Obtain the current state in an unsafe manner. + * + * This property is not thread-safe and parallel state updates will introduce a race condition when not + * handled properly. + * Such race conditions arise when using multiple data streams such as [Flow]s. + * + * Accessing and modifying the state this way will **circumvent ALL plugins** and will not make state updates atomic. + * + * Consider accessing state via [StateReceiver.withState] or [StateReceiver.updateState] instead. + */ +public inline val StateProvider.state get() = states.value + /** * A function that obtains current state and updates it atomically (in the thread context), and non-atomically in * the coroutine context, which means it can cause races when you want to update states in parallel. diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt index 8980c7ab..4b42b96d 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt @@ -1,16 +1,17 @@ +@file:OptIn(InternalFlowMVIAPI::class) + package pro.respawn.flowmvi.modules import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex -import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.api.ImmediateStateReceiver import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext -import pro.respawn.flowmvi.api.StateProvider import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.util.withReentrantLock @@ -18,16 +19,13 @@ internal class StateModule( initial: S, atomic: Boolean, private val transform: (suspend PipelineContext.(old: S, new: S) -> S?)? -) : StateProvider, ImmediateStateReceiver { +) : ImmediateStateReceiver { @Suppress("VariableNaming") private val _states = MutableStateFlow(initial) override val states: StateFlow = _states.asStateFlow() private val mutex = if (atomic) Mutex() else null - @DelicateStoreApi - override val state: S by states::value - override fun compareAndSet(expect: S, new: S) = _states.compareAndSet(expect, new) suspend inline fun withState( diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt index b4095232..2e5156b0 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.awaitCancellation import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.intent import pro.respawn.flowmvi.dsl.send +import pro.respawn.flowmvi.dsl.state import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.util.TestAction diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt index 44eb79fb..b716f574 100644 --- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt @@ -1,8 +1,9 @@ package pro.respawn.flowmvi.test +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withTimeout -import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState @@ -14,14 +15,14 @@ import kotlin.test.assertIs /** * A class which implements a dsl for testing [Store]. */ +@OptIn(InternalFlowMVIAPI::class) public class StoreTestScope @PublishedApi internal constructor( public val provider: Provider, public val store: Store, public val timeoutMs: Long = 3000L, ) : Store by store, Provider by provider { - @OptIn(DelicateStoreApi::class) - override val state: S by store::state + override val states: StateFlow by provider::states override fun hashCode(): Int = store.hashCode() override fun equals(other: Any?): Boolean = store == other override suspend fun emit(intent: I): Unit = store.emit(intent) diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt index acd601f8..945c83a5 100644 --- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt @@ -4,8 +4,12 @@ package pro.respawn.flowmvi.test.plugin import kotlinx.atomicfu.atomic import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import pro.respawn.flowmvi.annotation.ExperimentalFlowMVIAPI +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.annotation.NotIntendedForInheritance import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.api.MVIAction @@ -15,11 +19,12 @@ import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.StoreConfiguration import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle +import pro.respawn.flowmvi.dsl.state import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.test.TestStoreLifecycle import pro.respawn.flowmvi.test.ensureStarted -@OptIn(ExperimentalFlowMVIAPI::class, NotIntendedForInheritance::class) +@OptIn(ExperimentalFlowMVIAPI::class, NotIntendedForInheritance::class, InternalFlowMVIAPI::class) internal class TestPipelineContext @PublishedApi internal constructor( override val config: StoreConfiguration, val plugin: StorePlugin, @@ -27,9 +32,8 @@ internal class TestPipelineContext @ override val coroutineContext by config::coroutineContext - private var _state = atomic(config.initial) - override val state by _state::value - + private val _state = MutableStateFlow(config.initial) + override val states: StateFlow = _state.asStateFlow() override fun compareAndSet(old: S, new: S): Boolean = _state.compareAndSet(old, new) @DelicateStoreApi @@ -54,7 +58,7 @@ internal class TestPipelineContext @ override suspend fun updateState(transform: suspend S.() -> S) = with(plugin) { ensureStarted() - updateStateImmediate { onState(state, state.transform()) ?: this } + updateStateImmediate { onState(this, transform()) ?: this } } override suspend fun withState(block: suspend S.() -> Unit) { From 4983b04acead0c60393f3ceb9da185037ddfdb14 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 10:20:10 +0100 Subject: [PATCH 15/36] feat: optimize performance of atomic state updates by reusing context --- .../kotlin/pro/respawn/flowmvi/benchmarks/Main.kt | 4 ++-- .../kotlin/pro/respawn/flowmvi/modules/StateModule.kt | 6 +++++- .../kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt | 10 ++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt index 6bcc518e..f59f23ba 100644 --- a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt +++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt @@ -6,11 +6,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment -import pro.respawn.flowmvi.benchmarks.setup.optimized.optimizedStore +import pro.respawn.flowmvi.benchmarks.setup.atomic.atomicParallelStore fun main() = runBlocking { println(ProcessHandle.current().pid()) - val store = optimizedStore(this) + val store = atomicParallelStore(this) launch { while (isActive) { store.intent(Increment) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt index 4b42b96d..afbb515c 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt @@ -13,8 +13,11 @@ import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.dsl.updateStateImmediate +import pro.respawn.flowmvi.util.ReentrantMutexContextElement +import pro.respawn.flowmvi.util.ReentrantMutexContextKey import pro.respawn.flowmvi.util.withReentrantLock + internal class StateModule( initial: S, atomic: Boolean, @@ -24,7 +27,8 @@ internal class StateModule( @Suppress("VariableNaming") private val _states = MutableStateFlow(initial) override val states: StateFlow = _states.asStateFlow() - private val mutex = if (atomic) Mutex() else null + private val mutex = if (!atomic) null else + Mutex().let(::ReentrantMutexContextKey).let(::ReentrantMutexContextElement) override fun compareAndSet(expect: S, new: S) = _states.compareAndSet(expect, new) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt index d6585e73..59f26dca 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt @@ -8,14 +8,16 @@ import kotlin.coroutines.coroutineContext import kotlin.jvm.JvmInline @PublishedApi -internal suspend inline fun Mutex?.withReentrantLock(crossinline block: suspend () -> T): T { +internal suspend inline fun ReentrantMutexContextElement?.withReentrantLock( + crossinline block: suspend () -> T +): T { if (this == null) return block() - val key = ReentrantMutexContextKey(this) + val key = this.key // call block directly when this mutex is already locked in the context if (coroutineContext[key] != null) return block() // otherwise add it to the context and lock the mutex - return withContext(ReentrantMutexContextElement(key)) { - withLock { block() } + return withContext(this) { + key.mutex.withLock { block() } } } From 727a2234542bca652d97137852b28b835983e2c5 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 11:17:33 +0100 Subject: [PATCH 16/36] feat: skip recover machinery when there is no handler defined --- .../kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt index b6b46085..f1df3fe8 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt @@ -24,6 +24,8 @@ internal class RecoverModule( override val key: CoroutineContext.Key<*> get() = RecoverModule + val hasHandler = handler != null + suspend fun PipelineContext.handle(e: Exception) { if (handler == null) throw UnhandledStoreException(e) handler.invoke(this@handle, e)?.let { throw UnhandledStoreException(it) } @@ -56,6 +58,7 @@ internal suspend inline fun Pipe } catch (expected: Exception) { when { expected is CancellationException || expected is UnrecoverableException -> throw expected + !recover.hasHandler -> throw UnhandledStoreException(expected) alreadyRecovered() -> throw RecursiveRecoverException(expected) else -> withContext(recover) { recover.run { handle(expected) } @@ -69,6 +72,7 @@ internal fun PipelineContext when { e !is Exception || e is CancellationException -> throw e + !recover.hasHandler -> throw UnhandledStoreException(e) e is UnrecoverableException -> throw e.unwrapRecursion() ctx.alreadyRecovered -> throw e // add Recoverable to the coroutine context From 9428965857393634c1081928bc7e015e369b1423 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 11:20:22 +0100 Subject: [PATCH 17/36] feat: implement StateStrategy with new optimized atomic behavior --- .../kotlin/pro/respawn/flowmvi/StoreImpl.kt | 5 +- .../pro/respawn/flowmvi/api/StateStrategy.kt | 48 +++++++++++++++++++ .../respawn/flowmvi/api/StoreConfiguration.kt | 14 ++++-- .../flowmvi/dsl/StoreConfigurationBuilder.kt | 46 +++++++++++------- .../exceptions/InternalStoreExceptions.kt | 10 ++++ .../respawn/flowmvi/modules/StateModule.kt | 44 +++++++++++++---- .../pro/respawn/flowmvi/util/ReentrantLock.kt | 11 ++--- 7 files changed, 141 insertions(+), 37 deletions(-) create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateStrategy.kt diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt index a02ac48a..1b497638 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt @@ -46,8 +46,9 @@ internal class StoreImpl( private val recover: RecoverModule = RecoverModule(plugin.onException), private val stateModule: StateModule = StateModule( config.initial, - config.atomicStateUpdates, - plugin.onState + config.stateStrategy, + config.debuggable, + plugin.onState, ), ) : Store, Provider, diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateStrategy.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateStrategy.kt new file mode 100644 index 00000000..175daf21 --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateStrategy.kt @@ -0,0 +1,48 @@ +package pro.respawn.flowmvi.api + +import pro.respawn.flowmvi.dsl.updateStateImmediate + +/** + * Defines available strategies [Store] can use when a state + * operation ([StateReceiver.updateState] or [StateReceiver.withState]) + * is requested. + * + * Set during store configuration. + */ +public sealed interface StateStrategy { + + /** + * State transactions are not [Atomic] (not serializable). This means ` + * [StateReceiver.updateState] and [StateReceiver.withState] functions are + * no-op and forward to [updateStateImmediate]. + * + * This leads to the following consequences: + * 1. The order of state operations is **undefined** in parallel contexts. + * 2. There is **no thread-safety** for state reads and writes. + * 3. State operation **performance is increased** significantly (about 10x faster) + * + * * Be very careful with this strategy and use it when you will ensure the safety of updates manually **and** you + * absolutely must squeeze the maximum performance out of a Store. Do not optimize prematurely. + * * For a semi-safe but faster alternative, consider using [Atomic] with [Atomic.reentrant] set to `false`. + * * This strategy configures state transactions for the whole Store. + * For one-time usage of non-atomic updates, see [updateStateImmediate]. + */ + public object Immediate : StateStrategy + + /** + * Enables transaction serialization for state updates, making state updates atomic and suspendable. + * + * * Synchronizes state updates, allowing only **one** client to read and/or update the state at a time. + * All other clients attempting to get the state will wait on a FIFO queue and suspend the parent coroutine. + * * This strategy configures state transactions for the whole store. + * For one-time usage of non-atomic updates, see [updateStateImmediate]. + * * Has a small performance impact because of coroutine context switching and mutex usage. + * + * * Performance impact can be minimized at the cost of lock reentrancy. Set [reentrant] to `false` to use it, but + * **HERE BE DRAGONS** if you do that, as using the state within another state transaction will + * cause a **permanent deadlock**. + */ + public data class Atomic( + val reentrant: Boolean = true, + ) : StateStrategy +} diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt index c8a6a557..859df6a6 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt @@ -10,18 +10,26 @@ import kotlin.coroutines.CoroutineContext * * Please see [StoreConfigurationBuilder] for details on the meaning behind the properties listed here */ +@ConsistentCopyVisibility @Suppress("UndocumentedPublicProperty") -public data class StoreConfiguration( +public data class StoreConfiguration internal constructor( val initial: S, val allowIdleSubscriptions: Boolean, val parallelIntents: Boolean, val actionShareBehavior: ActionShareBehavior, + val stateStrategy: StateStrategy, val intentCapacity: Int, val onOverflow: BufferOverflow, val debuggable: Boolean, val coroutineContext: CoroutineContext, val logger: StoreLogger, - val atomicStateUpdates: Boolean, val verifyPlugins: Boolean, val name: String?, -) +) { + + @Deprecated( + "Please use the StateStrategy directly", + ReplaceWith("this.stateStrategy is StateStrategy.Atomic") + ) + val atomicStateUpdates: Boolean get() = stateStrategy is StateStrategy.Atomic +} diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt index b6df2489..47c25ed6 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt @@ -7,6 +7,7 @@ import pro.respawn.flowmvi.api.FlowMVIDSL import pro.respawn.flowmvi.api.IntentReceiver import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.StateStrategy import pro.respawn.flowmvi.api.Store import pro.respawn.flowmvi.api.StoreConfiguration import pro.respawn.flowmvi.logging.NoOpStoreLogger @@ -42,6 +43,20 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() { @FlowMVIDSL public var actionShareBehavior: ActionShareBehavior = ActionShareBehavior.Distribute() + /** + * Configure the [StateStrategy] of this [Store]. + * + * Available strategies: + * * [StateStrategy.Atomic] + * * [StateStrategy.Immediate] + * + * Make sure to read the documentation of the strategy before modifying this property. + * + * [StateStrategy.Atomic] with [StateStrategy.Atomic.reentrant] = `true` by default + */ + @FlowMVIDSL + public var stateStrategy: StateStrategy = StateStrategy.Atomic(true) + /** * Designate the maximum capacity of [MVIIntent]s waiting for processing * in the [pro.respawn.flowmvi.api.IntentReceiver]'s queue. @@ -97,21 +112,6 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() { @FlowMVIDSL public var logger: StoreLogger? = null - /** - * Enables transaction serialization for state updates, making state updates atomic and suspendable. - * - * * Serializes both state reads and writes using a mutex. - * * Synchronizes state updates, allowing only **one** client to read and/or update the state at a time. - * All other clients attempt to get the state will wait on a FIFO queue and suspend the parent coroutine. - * * This property disables state transactions for the whole store. - * For one-time usage of non-atomic updates, see [updateStateImmediate]. - * * Has a small performance impact because of coroutine context switching and mutex usage. - * - * `true` by default - */ - @FlowMVIDSL - public var atomicStateUpdates: Boolean = true - /** * Signals to plugins that they should enable their own verification logic. * @@ -129,6 +129,20 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() { @FlowMVIDSL public var name: String? = null + // region deprecated + @Deprecated( + "Please use the StateStrategy property", + replaceWith = ReplaceWith("stateStrategy = StateStrategy.Atomic()"), + ) + @FlowMVIDSL + @Suppress("UndocumentedPublicProperty") + public var atomicStateUpdates: Boolean + get() = stateStrategy is StateStrategy.Atomic + set(value) { + stateStrategy = if (value) StateStrategy.Atomic(true) else StateStrategy.Immediate + } + // endregion + /** * Create the [StoreConfiguration] */ @@ -142,10 +156,10 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() { debuggable = debuggable, coroutineContext = coroutineContext, logger = logger ?: if (debuggable) PlatformStoreLogger else NoOpStoreLogger, - atomicStateUpdates = atomicStateUpdates, name = name, allowIdleSubscriptions = allowIdleSubscriptions ?: !debuggable, verifyPlugins = verifyPlugins ?: debuggable, + stateStrategy = stateStrategy, ) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt index c4cf9d0e..5ab511be 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt @@ -57,3 +57,13 @@ internal class SubscribeBeforeStartException(cause: Exception? = null) : Unrecov If not, please always call Store.start() before you try using it. """.trimIndent() ) + +internal class RecursiveStateTransactionException(cause: Exception) : UnrecoverableException( + cause = cause, + message = """ + Tried to use start a state transaction while already in one. + This happened because state transactions are Atomic and reentrant = false. + Please avoid using recursion in transactions, otherwise you will get a permanent deadlock, or use + StateStrategy.Atomic.reentrant = true at the cost of some performance. + """.trimIndent() +) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt index afbb515c..ea786bd1 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt @@ -6,40 +6,66 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.api.ImmediateStateReceiver import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StateStrategy +import pro.respawn.flowmvi.api.StateStrategy.Atomic +import pro.respawn.flowmvi.api.StateStrategy.Immediate import pro.respawn.flowmvi.dsl.updateStateImmediate +import pro.respawn.flowmvi.exceptions.RecursiveStateTransactionException import pro.respawn.flowmvi.util.ReentrantMutexContextElement import pro.respawn.flowmvi.util.ReentrantMutexContextKey import pro.respawn.flowmvi.util.withReentrantLock - internal class StateModule( initial: S, - atomic: Boolean, - private val transform: (suspend PipelineContext.(old: S, new: S) -> S?)? + strategy: StateStrategy, + private val debuggable: Boolean, + private val chain: (suspend PipelineContext.(old: S, new: S) -> S?)? ) : ImmediateStateReceiver { @Suppress("VariableNaming") private val _states = MutableStateFlow(initial) override val states: StateFlow = _states.asStateFlow() - private val mutex = if (!atomic) null else - Mutex().let(::ReentrantMutexContextKey).let(::ReentrantMutexContextElement) + private val reentrant = strategy is Atomic && strategy.reentrant + private val mutexElement = when (strategy) { + is Immediate -> null + is Atomic -> Mutex().let(::ReentrantMutexContextKey).let(::ReentrantMutexContextElement) + } + + private suspend inline fun withLock(crossinline block: suspend () -> Unit) = when { + mutexElement == null -> block() + reentrant -> mutexElement.withReentrantLock(block) + !debuggable -> mutexElement.key.mutex.withLock { block() } + else -> { + try { + mutexElement.key.mutex.lock(this) + } catch (e: IllegalStateException) { + throw RecursiveStateTransactionException(e) + } + try { + block() + } finally { + mutexElement.key.mutex.unlock(this) + } + } + } override fun compareAndSet(expect: S, new: S) = _states.compareAndSet(expect, new) suspend inline fun withState( crossinline block: suspend S.() -> Unit - ) = mutex.withReentrantLock { block(states.value) } + ) = withLock { block(states.value) } suspend inline fun PipelineContext.useState( crossinline transform: suspend S.() -> S - ) = mutex.withReentrantLock block@{ - val delegate = this@StateModule.transform ?: return@block updateStateImmediate { transform() } - updateStateImmediate { delegate(this, transform()) ?: this } + ) = withLock { + val chain = chain ?: return@withLock updateStateImmediate { transform() } + updateStateImmediate { chain(this, transform()) ?: this } } } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt index 59f26dca..495ebc19 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt @@ -10,15 +10,12 @@ import kotlin.jvm.JvmInline @PublishedApi internal suspend inline fun ReentrantMutexContextElement?.withReentrantLock( crossinline block: suspend () -> T -): T { - if (this == null) return block() - val key = this.key +) = when { + this == null -> block() // call block directly when this mutex is already locked in the context - if (coroutineContext[key] != null) return block() + coroutineContext[key] != null -> block() // otherwise add it to the context and lock the mutex - return withContext(this) { - key.mutex.withLock { block() } - } + else -> withContext(this) { key.mutex.withLock { block() } } } @JvmInline From 5d5d4f807e40dff58fdad53913e1e15be822f5f2 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 12:05:26 +0100 Subject: [PATCH 18/36] feat: implement validations for non-reentrant store's recursive state transactions --- .../exceptions/InternalStoreExceptions.kt | 5 +++-- .../pro/respawn/flowmvi/modules/StateModule.kt | 17 +++-------------- .../pro/respawn/flowmvi/util/ReentrantLock.kt | 12 ++++++++++-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt index 5ab511be..7fd297d9 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt @@ -58,10 +58,11 @@ internal class SubscribeBeforeStartException(cause: Exception? = null) : Unrecov """.trimIndent() ) -internal class RecursiveStateTransactionException(cause: Exception) : UnrecoverableException( +@PublishedApi +internal class RecursiveStateTransactionException(cause: Exception?) : UnrecoverableException( cause = cause, message = """ - Tried to use start a state transaction while already in one. + You have tried to start a state transaction while already in one. This happened because state transactions are Atomic and reentrant = false. Please avoid using recursion in transactions, otherwise you will get a permanent deadlock, or use StateStrategy.Atomic.reentrant = true at the cost of some performance. diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt index ea786bd1..d0d8d3c0 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt @@ -17,10 +17,10 @@ import pro.respawn.flowmvi.api.StateStrategy import pro.respawn.flowmvi.api.StateStrategy.Atomic import pro.respawn.flowmvi.api.StateStrategy.Immediate import pro.respawn.flowmvi.dsl.updateStateImmediate -import pro.respawn.flowmvi.exceptions.RecursiveStateTransactionException import pro.respawn.flowmvi.util.ReentrantMutexContextElement import pro.respawn.flowmvi.util.ReentrantMutexContextKey import pro.respawn.flowmvi.util.withReentrantLock +import pro.respawn.flowmvi.util.withValidatedLock internal class StateModule( initial: S, @@ -41,19 +41,8 @@ internal class StateModule( private suspend inline fun withLock(crossinline block: suspend () -> Unit) = when { mutexElement == null -> block() reentrant -> mutexElement.withReentrantLock(block) - !debuggable -> mutexElement.key.mutex.withLock { block() } - else -> { - try { - mutexElement.key.mutex.lock(this) - } catch (e: IllegalStateException) { - throw RecursiveStateTransactionException(e) - } - try { - block() - } finally { - mutexElement.key.mutex.unlock(this) - } - } + debuggable -> mutexElement.withValidatedLock(block) + else -> mutexElement.key.mutex.withLock { block() } } override fun compareAndSet(expect: S, new: S) = _states.compareAndSet(expect, new) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt index 495ebc19..6386dde3 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt @@ -3,21 +3,29 @@ package pro.respawn.flowmvi.util import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import pro.respawn.flowmvi.exceptions.RecursiveStateTransactionException import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext import kotlin.jvm.JvmInline @PublishedApi -internal suspend inline fun ReentrantMutexContextElement?.withReentrantLock( +internal suspend inline fun ReentrantMutexContextElement.withReentrantLock( crossinline block: suspend () -> T ) = when { - this == null -> block() // call block directly when this mutex is already locked in the context coroutineContext[key] != null -> block() // otherwise add it to the context and lock the mutex else -> withContext(this) { key.mutex.withLock { block() } } } +@PublishedApi +internal suspend inline fun ReentrantMutexContextElement.withValidatedLock( + crossinline block: suspend () -> T +) = when { + coroutineContext[key] != null -> throw RecursiveStateTransactionException(null) + else -> withContext(this) { key.mutex.withLock { block() } } +} + @JvmInline @PublishedApi internal value class ReentrantMutexContextElement( From 42bbe31e9d314c4086fbc67abe0d058228f60af9 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 12:07:05 +0100 Subject: [PATCH 19/36] fix: prevent store test dsl leaks when test method throws --- .../kotlin/pro/respawn/flowmvi/test/TestDsl.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt index 7a7b76d5..10fb9bc8 100644 --- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt @@ -9,6 +9,7 @@ import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.Store import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle +import pro.respawn.flowmvi.dsl.collect import kotlin.test.assertTrue /** @@ -17,9 +18,12 @@ import kotlin.test.assertTrue public suspend inline fun Store.test( crossinline block: suspend Store.() -> Unit ): Unit = coroutineScope { - start(this) - block() - closeAndWait() + try { + start(this) + block() + } finally { + closeAndWait() + } } /** @@ -29,10 +33,8 @@ public suspend inline fun Store Store.subscribeAndTest( crossinline block: suspend StoreTestScope.() -> Unit, ): Unit = test { - coroutineScope { - subscribe { - StoreTestScope(this, this@subscribeAndTest).run { block() } - } + collect { + StoreTestScope(this, this@subscribeAndTest).run { block() } } } From c450796d5cb75bbecafd612411d26c842e58349d Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 12:10:10 +0100 Subject: [PATCH 20/36] feat: implement allowTransientSubscriptions property to avoid validation of subscriptions even on debug --- .../kotlin/pro/respawn/flowmvi/StoreImpl.kt | 2 +- .../respawn/flowmvi/api/StoreConfiguration.kt | 1 + .../flowmvi/dsl/StoreConfigurationBuilder.kt | 17 +++++++++++++++-- .../pro/respawn/flowmvi/util/TestStore.kt | 6 ++++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt index 1b497638..45b512e6 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt @@ -104,7 +104,7 @@ internal class StoreImpl( if (!isActive && !config.allowIdleSubscriptions) throw SubscribeBeforeStartException() launch { awaitUnsubscription() } block(this@StoreImpl) - if (config.debuggable) throw NonSuspendingSubscriberException() + if (!config.allowTransientSubscriptions) throw NonSuspendingSubscriberException() cancel() } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt index 859df6a6..4580c99a 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt @@ -15,6 +15,7 @@ import kotlin.coroutines.CoroutineContext public data class StoreConfiguration internal constructor( val initial: S, val allowIdleSubscriptions: Boolean, + val allowTransientSubscriptions: Boolean, val parallelIntents: Boolean, val actionShareBehavior: ActionShareBehavior, val stateStrategy: StateStrategy, diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt index 47c25ed6..d8dc825d 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt @@ -93,6 +93,18 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() { @FlowMVIDSL public var allowIdleSubscriptions: Boolean? = null + /** + * Whether to allow subscribers that can unsubscribe on their own. + * + * Normally, if a subscriber appears, but their subscription coroutine ends (i.e. they did not suspend forever), + * an exception will be thrown. This can indicate an error in the client code (e.g. forgot to collect a flow), but + * can be intended behavior sometimes. + * + * By default, this is enabled if [debuggable] is `true`. + */ + @FlowMVIDSL + public var allowTransientSubscriptions: Boolean? = null + /** * A coroutine context overrides for the [Store]. * This context will be merged with the one the store was launched with (e.g. `viewModelScope`). @@ -155,11 +167,12 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() { onOverflow = onOverflow, debuggable = debuggable, coroutineContext = coroutineContext, - logger = logger ?: if (debuggable) PlatformStoreLogger else NoOpStoreLogger, name = name, + logger = logger ?: if (debuggable) PlatformStoreLogger else NoOpStoreLogger, allowIdleSubscriptions = allowIdleSubscriptions ?: !debuggable, + allowTransientSubscriptions = allowTransientSubscriptions ?: !debuggable, verifyPlugins = verifyPlugins ?: debuggable, - stateStrategy = stateStrategy, + stateStrategy = stateStrategy ) } diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt index e708593e..967864ef 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt @@ -4,6 +4,7 @@ package pro.respawn.flowmvi.util import pro.respawn.flowmvi.annotation.ExperimentalFlowMVIAPI import pro.respawn.flowmvi.api.ActionShareBehavior +import pro.respawn.flowmvi.api.StateStrategy import pro.respawn.flowmvi.decorator.DecoratorBuilder import pro.respawn.flowmvi.decorator.decorator import pro.respawn.flowmvi.dsl.BuildStore @@ -31,10 +32,11 @@ internal fun testStore( configure: BuildStore, TestAction> = {}, ) = store(initial) { configure { - debuggable = false + debuggable = true + allowTransientSubscriptions = true name = "TestStore" actionShareBehavior = behavior - atomicStateUpdates = true + stateStrategy = StateStrategy.Atomic() logger = PlatformStoreLogger } enableLogging() From fa97c9466ca9d1e3aec1f0f862ead67a901879f5 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 12:14:02 +0100 Subject: [PATCH 21/36] chore: implement recursive state updates fix --- .../flowmvi/test/store/StoreLaunchTest.kt | 6 +++- .../flowmvi/test/store/StoreStatesTest.kt | 31 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt index f55682ce..b3ec0bb3 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt @@ -25,7 +25,11 @@ class StoreLaunchTest : FreeSpec({ val timeTravel = testTimeTravel() afterEach { timeTravel.reset() } "Given store" - { - val store = testStore(timeTravel) + val store = testStore(timeTravel) { + configure { + allowIdleSubscriptions = true + } + } "then can be launched and stopped" { coroutineScope { val job = shouldNotThrowAny { diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt index 2e5156b0..2e84a1b1 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt @@ -1,14 +1,17 @@ package pro.respawn.flowmvi.test.store import app.cash.turbine.test +import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import pro.respawn.flowmvi.api.StateStrategy.Atomic import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.intent -import pro.respawn.flowmvi.dsl.send import pro.respawn.flowmvi.dsl.state import pro.respawn.flowmvi.dsl.updateStateImmediate +import pro.respawn.flowmvi.exceptions.RecursiveStateTransactionException import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.util.TestAction import pro.respawn.flowmvi.util.TestState @@ -18,7 +21,9 @@ import pro.respawn.flowmvi.util.testStore import pro.respawn.flowmvi.util.testTimeTravel class StoreStatesTest : FreeSpec({ + asUnconfined() + val timeTravel = testTimeTravel() beforeEach { timeTravel.reset() } @@ -79,4 +84,28 @@ class StoreStatesTest : FreeSpec({ } } } + "given non-reentrant atomic store" - { + val store = testStore { + configure { + stateStrategy = Atomic(reentrant = false) + parallelIntents = false + } + } + "and recursive intent that blocks state" - { + val blockingIntent = LambdaIntent { + updateState { + updateState { awaitCancellation() } + this + } + } + // TODO: Throws correctly, but crashes the test suite + "then store throws".config(enabled = false) { + shouldThrowExactly { + store.subscribeAndTest { + emit(blockingIntent) + } + } + } + } + } }) From c5351e4240a62352cc2191d425188394c7d926f8 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 7 Dec 2024 12:43:29 +0100 Subject: [PATCH 22/36] chore: fix build --- .../pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt | 3 ++- .../pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt | 1 + .../flowmvi/sample/features/undoredo/UndoRedoContainer.kt | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt index dd8308c7..64ecd7dc 100644 --- a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt +++ b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt @@ -26,6 +26,7 @@ import pro.respawn.flowmvi.debugger.model.ServerEvent.RollbackState import pro.respawn.flowmvi.debugger.model.ServerEvent.RollbackToInitialState import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.plugin +import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.logging.warn import pro.respawn.flowmvi.plugins.NoOpPlugin import pro.respawn.flowmvi.plugins.TimeTravel @@ -60,7 +61,7 @@ private fun DebugClientStore.asPlug timeTravel.states.lastIndex - 1 // ignore plugins, including self, to not loop the event )?.let { previous -> updateStateImmediate { previous } } - is RollbackToInitialState -> updateStateImmediate { config.initial } + is RollbackToInitialState -> updateStateImmediate { this@ctx.config.initial } is ServerEvent.Stop -> this@ctx.close() } } diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt index 5ef85f63..5881ef6b 100644 --- a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt +++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt @@ -11,6 +11,7 @@ import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.plugin +import pro.respawn.flowmvi.dsl.state import pro.respawn.flowmvi.essenty.dsl.retainedStore import pro.respawn.flowmvi.essenty.savedstate.ensureNotRegistered import pro.respawn.flowmvi.util.typed diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt index 37abe3bf..bce3c96f 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import pro.respawn.flowmvi.api.Container import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.dsl.updateStateImmediate import pro.respawn.flowmvi.plugins.recover import pro.respawn.flowmvi.plugins.reduce import pro.respawn.flowmvi.plugins.undoRedo From 70cd475d22bd08c387026f4d874840b7422100ad Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sun, 8 Dec 2024 11:04:15 +0100 Subject: [PATCH 23/36] fix: fix kotlin compiler bug with inlined code in try/catch --- .../kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt index f1df3fe8..8d20fb4f 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt @@ -37,14 +37,14 @@ internal class RecoverModule( private tailrec fun UnrecoverableException.unwrapRecursion(): Exception = when (val cause = cause) { null -> this this -> this // cause is the same exception - is UnrecoverableException -> cause.unwrapRecursion() is CancellationException -> throw cause + is UnrecoverableException -> cause.unwrapRecursion() else -> cause } -private suspend fun alreadyRecovered() = coroutineContext.alreadyRecovered +internal suspend inline fun alreadyRecovered() = coroutineContext.alreadyRecovered -private val CoroutineContext.alreadyRecovered get() = this[RecoverModule] != null +internal inline val CoroutineContext.alreadyRecovered get() = this[RecoverModule] != null /** * Run [block] catching any exceptions and invoking [recover]. This will add this [RecoverModule] key to the coroutine From 6a29cdda6d6668d2291118c1636047841a784666 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sun, 8 Dec 2024 12:37:27 +0100 Subject: [PATCH 24/36] feat: add reset state plugin --- .../flowmvi/plugins/ResetStatePlugin.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ResetStatePlugin.kt diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ResetStatePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ResetStatePlugin.kt new file mode 100644 index 00000000..0fcb685c --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ResetStatePlugin.kt @@ -0,0 +1,35 @@ +package pro.respawn.flowmvi.plugins + +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.dsl.plugin +import pro.respawn.flowmvi.dsl.updateStateImmediate + +private const val Name = "ResetStatePlugin" + +/** + * Normally, [Store]s do not reset their state back to the initial value when they are stopped. + * + * This plugin sets the state back to the initial value (defined in the builder) each time the store is stopped. + * + * Install this plugin first preferably. + * + * **This plugin can only be installed ONCE**. + **/ +public fun resetStatePlugin(): StorePlugin = plugin { + this.name = Name + onStop { e -> + updateStateImmediate { config.initial } + } +} + +/** + * Install a new [resetStatePlugin]. + **/ +public fun StoreBuilder.resetStateOnStop() = install( + resetStatePlugin() +) From 831badb7e90b148d8ac06748232c7356e6f7550d Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sun, 8 Dec 2024 12:39:35 +0100 Subject: [PATCH 25/36] fix: improve test stability --- .../flowmvi/dsl/StoreConfigurationBuilder.kt | 2 +- .../respawn/flowmvi/test/store/StoreStatesTest.kt | 14 +++++++++----- .../kotlin/pro/respawn/flowmvi/util/TestStore.kt | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt index d8dc825d..8d4ac724 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt @@ -144,7 +144,7 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() { // region deprecated @Deprecated( "Please use the StateStrategy property", - replaceWith = ReplaceWith("stateStrategy = StateStrategy.Atomic()"), + replaceWith = ReplaceWith("stateStrategy = StateStrategy.Immediate"), ) @FlowMVIDSL @Suppress("UndocumentedPublicProperty") diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt index 2e84a1b1..06849104 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt @@ -5,7 +5,7 @@ import io.kotest.assertions.throwables.shouldThrowExactly import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.launch import pro.respawn.flowmvi.api.StateStrategy.Atomic import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.intent @@ -30,13 +30,15 @@ class StoreStatesTest : FreeSpec({ "given lambdaIntent store" - { val store = testStore(timeTravel) { configure { - parallelIntents = true + parallelIntents = false } } "and intent that blocks state" - { val blockingIntent = LambdaIntent { - updateState { - awaitCancellation() + launch { + updateState { + awaitCancellation() + } } } "then state is never updated by another intent" { @@ -58,6 +60,7 @@ class StoreStatesTest : FreeSpec({ "then withState is never executed" { store.subscribeAndTest { emit(blockingIntent) + idle() intent { withState { throw AssertionError("WithState was executed") @@ -75,7 +78,8 @@ class StoreStatesTest : FreeSpec({ store.subscribeAndTest { states.test { awaitItem() shouldBe TestState.Some - intent(blockingIntent) + emit(blockingIntent) + idle() intent { updateStateImmediate { newState } } awaitItem() shouldBe newState state shouldBe newState diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt index 967864ef..5cd147f1 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt @@ -14,6 +14,7 @@ import pro.respawn.flowmvi.dsl.store import pro.respawn.flowmvi.logging.PlatformStoreLogger import pro.respawn.flowmvi.plugins.TimeTravel import pro.respawn.flowmvi.plugins.enableLogging +import pro.respawn.flowmvi.plugins.resetStateOnStop import pro.respawn.flowmvi.plugins.timeTravel internal typealias TestTimeTravel = TimeTravel, TestAction> @@ -28,17 +29,18 @@ internal fun testTimeTravel() = TestTimeTravel() internal fun testStore( timeTravel: TestTimeTravel = testTimeTravel(), initial: TestState = TestState.Some, - behavior: ActionShareBehavior = ActionShareBehavior.Distribute(), configure: BuildStore, TestAction> = {}, ) = store(initial) { configure { debuggable = true allowTransientSubscriptions = true name = "TestStore" - actionShareBehavior = behavior - stateStrategy = StateStrategy.Atomic() + actionShareBehavior = ActionShareBehavior.Distribute() + stateStrategy = StateStrategy.Atomic(reentrant = false) logger = PlatformStoreLogger + parallelIntents = false } + resetStateOnStop() enableLogging() timeTravel(timeTravel) configure() From 1d572c9edffae71662689773ad3c10c01b0cdbfc Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sun, 8 Dec 2024 12:51:07 +0100 Subject: [PATCH 26/36] feat: add decorator live template, improve plugin live templates --- .../src/main/resources/LiveTemplates.xml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/debugger/ideplugin/src/main/resources/LiveTemplates.xml b/debugger/ideplugin/src/main/resources/LiveTemplates.xml index 75d721de..34e469c5 100644 --- a/debugger/ideplugin/src/main/resources/LiveTemplates.xml +++ b/debugger/ideplugin/src/main/resources/LiveTemplates.xml @@ -1,5 +1,5 @@ -