From 14e6db64de6f4bbb8856890354423f395ed3f61b Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Tue, 2 Jun 2026 15:21:46 +0100 Subject: [PATCH 01/13] Add store-backed FlowStore to datasourcex-util A map whose entries are backed by an external store with a bounded in-memory hot set, read-through on miss, and changes exposed as a coroutine Flow of versioned deltas. --- util/api/datasourcex-util.api | 165 ++++++++++++++++++ util/build.gradle.kts | 2 + .../datasourcex/util/cache/SuspendingCache.kt | 62 +++++++ .../util/flow/VersionedMapEvent.kt | 20 +++ .../serialization/fory/DataSourceModule.kt | 2 + .../fory/VersionedMapEventSerializer.kt | 48 +++++ .../jackson2/DataSourceModule.kt | 18 ++ .../jackson2/VersionedMapEventDeserializer.kt | 34 ++++ .../jackson2/VersionedMapEventSerializer.kt | 34 ++++ .../jackson3/DataSourceModule.kt | 18 ++ .../jackson3/VersionedMapEventDeserializer.kt | 33 ++++ .../jackson3/VersionedMapEventSerializer.kt | 34 ++++ .../util/store/AbstractFlowStore.kt | 61 +++++++ .../datasourcex/util/store/CacheLoader.kt | 16 ++ .../util/store/CacheLoaderWriter.kt | 4 + .../datasourcex/util/store/CacheWriter.kt | 19 ++ .../datasourcex/util/store/FlowStore.kt | 90 ++++++++++ .../util/store/MutableFlowStore.kt | 73 ++++++++ .../util/store/TransactionRunner.kt | 6 + .../datasourcex/util/store/TxContext.kt | 42 +++++ .../datasourcex/util/store/VersionSource.kt | 30 ++++ .../datasourcex/util/store/Versioned.kt | 4 + .../util/cache/SuspendingCacheTest.kt | 88 ++++++++++ .../VersionedMapEventSerializationTest.kt | 31 ++++ .../VersionedMapEventSerializationTest.kt | 29 +++ .../VersionedMapEventSerializationTest.kt | 28 +++ .../datasourcex/util/store/FlowStoreTest.kt | 99 +++++++++++ .../util/store/InMemoryCacheLoaderWriter.kt | 39 +++++ .../store/InMemoryCacheLoaderWriterTest.kt | 36 ++++ .../util/store/MutableFlowStoreTest.kt | 83 +++++++++ .../util/store/VersionSourceTest.kt | 30 ++++ 31 files changed, 1278 insertions(+) create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/VersionedMapEvent.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializer.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializer.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializer.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Versioned.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializationTest.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index cecc302..915139d 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -143,6 +143,25 @@ public final class com/caplin/integration/datasourcex/util/TimeoutKt { public static final fun withTimeout-KLykuaI (JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class com/caplin/integration/datasourcex/util/cache/LoadingSuspendingCache : com/caplin/integration/datasourcex/util/cache/SuspendingCache { + public fun (Lcom/github/benmanes/caffeine/cache/AsyncCache;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function2;)V + public final fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public class com/caplin/integration/datasourcex/util/cache/SuspendingCache { + public fun (Lcom/github/benmanes/caffeine/cache/AsyncCache;Lkotlinx/coroutines/CoroutineScope;)V + public final fun asyncCache ()Lcom/github/benmanes/caffeine/cache/AsyncCache; + public final fun get (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getIfPresent (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun invalidate (Ljava/lang/Object;)V + public final fun put (Ljava/lang/Object;Ljava/lang/Object;)V +} + +public final class com/caplin/integration/datasourcex/util/cache/SuspendingCacheKt { + public static final fun buildSuspending (Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;)Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache; + public static final fun buildSuspending (Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function2;)Lcom/caplin/integration/datasourcex/util/cache/LoadingSuspendingCache; +} + public final class com/caplin/integration/datasourcex/util/flow/BufferKt { public static final fun bufferingDebounce (Lkotlinx/coroutines/flow/Flow;J)Lkotlinx/coroutines/flow/Flow; public static final fun bufferingDebounce (Lkotlinx/coroutines/flow/Flow;Ljava/time/Duration;)Lkotlinx/coroutines/flow/Flow; @@ -464,6 +483,39 @@ public final class com/caplin/integration/datasourcex/util/flow/ValueOrCompletio public static final fun materializeUnboxed (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; } +public abstract interface class com/caplin/integration/datasourcex/util/flow/VersionedMapEvent { + public abstract fun getKey ()Ljava/lang/Object; + public abstract fun getVersion ()J +} + +public final class com/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Removed : com/caplin/integration/datasourcex/util/flow/VersionedMapEvent { + public fun (Ljava/lang/Object;J)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()J + public final fun copy (Ljava/lang/Object;J)Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Removed; + public static synthetic fun copy$default (Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Removed;Ljava/lang/Object;JILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Removed; + public fun equals (Ljava/lang/Object;)Z + public fun getKey ()Ljava/lang/Object; + public fun getVersion ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Upsert : com/caplin/integration/datasourcex/util/flow/VersionedMapEvent { + public fun (Ljava/lang/Object;Ljava/lang/Object;J)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()Ljava/lang/Object; + public final fun component3 ()J + public final fun copy (Ljava/lang/Object;Ljava/lang/Object;J)Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Upsert; + public static synthetic fun copy$default (Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Upsert;Ljava/lang/Object;Ljava/lang/Object;JILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent$Upsert; + public fun equals (Ljava/lang/Object;)Z + public fun getKey ()Ljava/lang/Object; + public final fun getValue ()Ljava/lang/Object; + public fun getVersion ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/caplin/integration/datasourcex/util/serialization/fory/DataSourceModuleKt { public static final fun registerDataSourceSerializers (Lorg/apache/fory/Fory;Z)Lorg/apache/fory/Fory; public static synthetic fun registerDataSourceSerializers$default (Lorg/apache/fory/Fory;ZILjava/lang/Object;)Lorg/apache/fory/Fory; @@ -518,3 +570,116 @@ public final class com/caplin/integration/datasourcex/util/serialization/jackson public fun toObject (Ltools/jackson/databind/JsonNode;Ljava/lang/Class;)Ljava/lang/Object; } +public final class com/caplin/integration/datasourcex/util/store/AutoCommitTxContext : com/caplin/integration/datasourcex/util/store/TxContext { + public fun (Ljava/lang/Object;)V + public final fun commit (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getTransaction ()Ljava/lang/Object; + public fun onCommitEnd (Lkotlin/jvm/functions/Function1;)V + public fun onRollback (Lkotlin/jvm/functions/Function1;)V + public final fun rollback (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoader { + public abstract fun load (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun loadAll (Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun loadAllKeys ()Lkotlinx/coroutines/flow/Flow; +} + +public final class com/caplin/integration/datasourcex/util/store/CacheLoader$DefaultImpls { + public static fun loadAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun loadAllKeys (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter : com/caplin/integration/datasourcex/util/store/CacheLoader, com/caplin/integration/datasourcex/util/store/CacheWriter { +} + +public final class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter$DefaultImpls { + public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun loadAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun loadAllKeys (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;)Lkotlinx/coroutines/flow/Flow; + public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/CacheWriter { + public abstract fun delete (Ljava/lang/Object;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Ljava/util/Collection;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun writeAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/caplin/integration/datasourcex/util/store/CacheWriter$DefaultImpls { + public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Collection;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/caplin/integration/datasourcex/util/store/CountingVersionSource : com/caplin/integration/datasourcex/util/store/VersionSource { + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun next ()J +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/FlowStore { + public abstract fun asFlow ()Lkotlinx/coroutines/flow/Flow; + public abstract fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun valueFlow (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/caplin/integration/datasourcex/util/store/FlowStoreKt { + public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlinx/coroutines/CoroutineScope;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/MutableFlowStore : com/caplin/integration/datasourcex/util/store/FlowStore { + public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun putAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun putAll (Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun remove (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun remove (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/caplin/integration/datasourcex/util/store/MutableFlowStoreKt { + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;ILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;ILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; +} + +public final class com/caplin/integration/datasourcex/util/store/TimestampVersionSource : com/caplin/integration/datasourcex/util/store/VersionSource { + public fun ()V + public fun (Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun next ()J +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/TransactionRunner { + public abstract fun run (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/TxContext { + public abstract fun getTransaction ()Ljava/lang/Object; + public abstract fun onCommitEnd (Lkotlin/jvm/functions/Function1;)V + public fun onRollback (Lkotlin/jvm/functions/Function1;)V +} + +public final class com/caplin/integration/datasourcex/util/store/TxContext$DefaultImpls { + public static fun onRollback (Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/jvm/functions/Function1;)V +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/VersionSource { + public abstract fun next ()J +} + +public final class com/caplin/integration/datasourcex/util/store/Versioned { + public fun (Ljava/lang/Object;J)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()J + public final fun copy (Ljava/lang/Object;J)Lcom/caplin/integration/datasourcex/util/store/Versioned; + public static synthetic fun copy$default (Lcom/caplin/integration/datasourcex/util/store/Versioned;Ljava/lang/Object;JILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Versioned; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public final fun getVersion ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/util/build.gradle.kts b/util/build.gradle.kts index 0eaf024..b92aea7 100644 --- a/util/build.gradle.kts +++ b/util/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { api("org.jetbrains.kotlinx:kotlinx-coroutines-core") api("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm") api("com.fasterxml.jackson.core:jackson-core") + api("com.github.ben-manes.caffeine:caffeine") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation(libs.zjsonpatch) @@ -38,6 +39,7 @@ dependencies { testImplementation(libs.turbine) testImplementation(libs.kotest.assertions) testImplementation(libs.kotest.runner) + testImplementation(libs.mockk) testImplementation(libs.fory.core) testImplementation(libs.fory.kotlin) testImplementation(libs.jackson3.databind) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt new file mode 100644 index 0000000..0e99b87 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt @@ -0,0 +1,62 @@ +package com.caplin.integration.datasourcex.util.cache + +import com.github.benmanes.caffeine.cache.AsyncCache +import com.github.benmanes.caffeine.cache.Caffeine +import java.util.concurrent.CompletableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.future.await +import kotlinx.coroutines.future.future + +/** + * A coroutine wrapper over Caffeine's [AsyncCache] with single-flight loading. Loads run in a child + * [SupervisorJob] of [scope] rather than the caller's coroutine, so one caller cancelling its [get] + * does not fail the shared computation, and a failed load surfaces only to that caller without + * cancelling [scope]. [scope]'s dispatcher decides where loads run, so it is required (no default): + * a blocking loader needs [kotlinx.coroutines.Dispatchers.IO], not the default dispatcher. + */ +open class SuspendingCache( + private val cache: AsyncCache, + scope: CoroutineScope, +) { + private val loaderScope = + CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) + + fun asyncCache(): AsyncCache = cache + + suspend fun getIfPresent(key: K): V? = cache.getIfPresent(key)?.await() + + /** Returns the cached value, loading it (single-flight) via [loader] on a miss. */ + suspend fun get(key: K, loader: suspend (K) -> V): V = + cache.get(key) { k, _ -> loaderScope.future { loader(k) } }.await() + + /** Inserts a value directly, bypassing the loader. */ + fun put(key: K, value: V) { + cache.put(key, CompletableFuture.completedFuture(value)) + } + + fun invalidate(key: K) { + cache.synchronous().invalidate(key) + } +} + +/** A [SuspendingCache] with a fixed [loader] applied on every miss. */ +class LoadingSuspendingCache( + cache: AsyncCache, + scope: CoroutineScope, + private val loader: suspend (K) -> V, +) : SuspendingCache(cache, scope) { + suspend fun get(key: K): V = get(key, loader) +} + +/** Builds a [SuspendingCache] from a configured Caffeine builder. */ +fun Caffeine.buildSuspending( + scope: CoroutineScope +): SuspendingCache = SuspendingCache(buildAsync(), scope) + +/** Builds a [LoadingSuspendingCache] with a fixed suspend [loader]. */ +fun Caffeine.buildSuspending( + scope: CoroutineScope, + loader: suspend (K) -> V, +): LoadingSuspendingCache = LoadingSuspendingCache(buildAsync(), scope, loader) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/VersionedMapEvent.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/VersionedMapEvent.kt new file mode 100644 index 0000000..136bb43 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/VersionedMapEvent.kt @@ -0,0 +1,20 @@ +package com.caplin.integration.datasourcex.util.flow + +/** + * A map mutation carrying the [version] it was written at, for distribution to consumers that gate + * on `version >`. Unlike [MapEvent] there is no old value and no `Populated` marker: the stream is + * delta-only and current values are read from the store. + */ +sealed interface VersionedMapEvent { + val key: K + val version: Long + + data class Upsert( + override val key: K, + val value: V, + override val version: Long, + ) : VersionedMapEvent + + data class Removed(override val key: K, override val version: Long) : + VersionedMapEvent +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/DataSourceModule.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/DataSourceModule.kt index 8315a2c..20851e4 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/DataSourceModule.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/DataSourceModule.kt @@ -5,6 +5,7 @@ import com.caplin.integration.datasourcex.util.flow.MapEvent import com.caplin.integration.datasourcex.util.flow.SetEvent import com.caplin.integration.datasourcex.util.flow.SimpleMapEvent import com.caplin.integration.datasourcex.util.flow.ValueOrCompletion +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import org.apache.fory.Fory /** Registers serializers for internal types with the provided [Fory] instance. */ @@ -32,6 +33,7 @@ fun Fory.registerDataSourceSerializers(preserveExceptionTypes: Boolean = false): registerSerializer(MapEvent::class.java, MapEventSerializer::class.java) registerSerializer(SimpleMapEvent::class.java, SimpleMapEventSerializer::class.java) registerSerializer(SetEvent::class.java, SetEventSerializer::class.java) + registerSerializer(VersionedMapEvent::class.java, VersionedMapEventSerializer::class.java) registerSerializer( ValueOrCompletion::class.java, diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializer.kt new file mode 100644 index 0000000..bd49059 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializer.kt @@ -0,0 +1,48 @@ +package com.caplin.integration.datasourcex.util.serialization.fory + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import org.apache.fory.config.Config +import org.apache.fory.context.ReadContext +import org.apache.fory.context.WriteContext +import org.apache.fory.serializer.Serializer + +internal class VersionedMapEventSerializer(config: Config, type: Class>) : + Serializer>(config, type) { + + private enum class Type { + UPSERT, + REMOVED, + } + + override fun write(writeContext: WriteContext, value: VersionedMapEvent<*, *>) { + when (value) { + is VersionedMapEvent.Upsert -> { + writeContext.writeByte(Type.UPSERT.ordinal.toByte()) + writeContext.writeRef(value.key) + writeContext.writeRef(value.value) + writeContext.writeRef(value.version) + } + is VersionedMapEvent.Removed -> { + writeContext.writeByte(Type.REMOVED.ordinal.toByte()) + writeContext.writeRef(value.key) + writeContext.writeRef(value.version) + } + } + } + + override fun read(readContext: ReadContext): VersionedMapEvent<*, *> { + return when (Type.entries[readContext.readByte().toInt()]) { + Type.UPSERT -> { + val key = readContext.readRef() as Any + val value = readContext.readRef() as Any + val version = readContext.readRef() as Long + VersionedMapEvent.Upsert(key, value, version) + } + Type.REMOVED -> { + val key = readContext.readRef() as Any + val version = readContext.readRef() as Long + VersionedMapEvent.Removed(key, version) + } + } + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/DataSourceModule.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/DataSourceModule.kt index d145222..4bff589 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/DataSourceModule.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/DataSourceModule.kt @@ -5,6 +5,7 @@ import com.caplin.integration.datasourcex.util.flow.MapEvent import com.caplin.integration.datasourcex.util.flow.SetEvent import com.caplin.integration.datasourcex.util.flow.SimpleMapEvent import com.caplin.integration.datasourcex.util.flow.ValueOrCompletion +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.Module @@ -58,6 +59,23 @@ object DataSourceModule : SimpleModule() { simpleMapEventDeserializer as JsonDeserializer>, ) + val versionedMapEventSerializer = VersionedMapEventSerializer() + val versionedMapEventDeserializer = VersionedMapEventDeserializer() + + addSerializer(VersionedMapEvent::class.java, versionedMapEventSerializer) + addDeserializer(VersionedMapEvent::class.java, versionedMapEventDeserializer) + + @Suppress("UNCHECKED_CAST") + addSerializer( + VersionedMapEvent.Upsert::class.java, + versionedMapEventSerializer as JsonSerializer>, + ) + @Suppress("UNCHECKED_CAST") + addSerializer( + VersionedMapEvent.Removed::class.java, + versionedMapEventSerializer as JsonSerializer>, + ) + val setEventSerializer = SetEventSerializer() val setEventDeserializer = SetEventDeserializer() diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt new file mode 100644 index 0000000..d50f0f1 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt @@ -0,0 +1,34 @@ +package com.caplin.integration.datasourcex.util.serialization.jackson2 + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.node.ObjectNode + +internal class VersionedMapEventDeserializer : + StdDeserializer>(VersionedMapEvent::class.java) { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): VersionedMapEvent<*, *> { + val node = p.codec.readTree(p) + val type = + node.get("type")?.asText() + ?: throw JsonMappingException.from(p, "Missing type field for VersionedMapEvent") + val key = + node.get("key")?.let { p.codec.treeToValue(it, Any::class.java) } + ?: throw JsonMappingException.from(p, "Missing key field for VersionedMapEvent") + val version = + node.get("version")?.asLong() + ?: throw JsonMappingException.from(p, "Missing version field for VersionedMapEvent") + return when (type) { + "upsert" -> { + val value = + node.get("value")?.let { p.codec.treeToValue(it, Any::class.java) } + ?: throw JsonMappingException.from(p, "Missing value field for upsert") + VersionedMapEvent.Upsert(key, value, version) + } + "removed" -> VersionedMapEvent.Removed(key, version) + else -> throw JsonMappingException.from(p, "Unknown VersionedMapEvent type: $type") + } + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializer.kt new file mode 100644 index 0000000..3b8b997 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializer.kt @@ -0,0 +1,34 @@ +package com.caplin.integration.datasourcex.util.serialization.jackson2 + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +internal class VersionedMapEventSerializer : + StdSerializer>(VersionedMapEvent::class.java) { + override fun serialize( + value: VersionedMapEvent<*, *>, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + when (value) { + is VersionedMapEvent.Upsert -> { + gen.writeStringField("type", "upsert") + gen.writeFieldName("key") + provider.defaultSerializeValue(value.key, gen) + gen.writeFieldName("value") + provider.defaultSerializeValue(value.value, gen) + gen.writeNumberField("version", value.version) + } + is VersionedMapEvent.Removed -> { + gen.writeStringField("type", "removed") + gen.writeFieldName("key") + provider.defaultSerializeValue(value.key, gen) + gen.writeNumberField("version", value.version) + } + } + gen.writeEndObject() + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/DataSourceModule.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/DataSourceModule.kt index 794ca83..cd652ea 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/DataSourceModule.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/DataSourceModule.kt @@ -5,6 +5,7 @@ import com.caplin.integration.datasourcex.util.flow.MapEvent import com.caplin.integration.datasourcex.util.flow.SetEvent import com.caplin.integration.datasourcex.util.flow.SimpleMapEvent import com.caplin.integration.datasourcex.util.flow.ValueOrCompletion +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import tools.jackson.databind.ValueDeserializer import tools.jackson.databind.ValueSerializer import tools.jackson.databind.json.JsonMapper @@ -64,6 +65,23 @@ object DataSourceModule : SimpleModule() { simpleMapEventDeserializer as ValueDeserializer>, ) + val versionedMapEventSerializer = VersionedMapEventSerializer() + val versionedMapEventDeserializer = VersionedMapEventDeserializer() + + addSerializer(VersionedMapEvent::class.java, versionedMapEventSerializer) + addDeserializer(VersionedMapEvent::class.java, versionedMapEventDeserializer) + + @Suppress("UNCHECKED_CAST") + addSerializer( + VersionedMapEvent.Upsert::class.java, + versionedMapEventSerializer as ValueSerializer>, + ) + @Suppress("UNCHECKED_CAST") + addSerializer( + VersionedMapEvent.Removed::class.java, + versionedMapEventSerializer as ValueSerializer>, + ) + val setEventSerializer = SetEventSerializer() val setEventDeserializer = SetEventDeserializer() diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt new file mode 100644 index 0000000..cfa2e09 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt @@ -0,0 +1,33 @@ +package com.caplin.integration.datasourcex.util.serialization.jackson3 + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import tools.jackson.core.JsonParser +import tools.jackson.databind.DatabindException +import tools.jackson.databind.DeserializationContext +import tools.jackson.databind.deser.std.StdDeserializer + +internal class VersionedMapEventDeserializer : + StdDeserializer>(VersionedMapEvent::class.java) { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): VersionedMapEvent<*, *> { + val node = ctxt.readTree(p) + val type = + node.get("type")?.asString() + ?: throw DatabindException.from(p, "Missing type field for VersionedMapEvent") + val key = + node.get("key")?.let { ctxt.readTreeAsValue(it, Any::class.java) } + ?: throw DatabindException.from(p, "Missing key field for VersionedMapEvent") + val version = + node.get("version")?.asLong() + ?: throw DatabindException.from(p, "Missing version field for VersionedMapEvent") + return when (type) { + "upsert" -> { + val value = + node.get("value")?.let { ctxt.readTreeAsValue(it, Any::class.java) } + ?: throw DatabindException.from(p, "Missing value field for upsert") + VersionedMapEvent.Upsert(key, value, version) + } + "removed" -> VersionedMapEvent.Removed(key, version) + else -> throw DatabindException.from(p, "Unknown VersionedMapEvent type: $type") + } + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializer.kt new file mode 100644 index 0000000..e15f932 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializer.kt @@ -0,0 +1,34 @@ +package com.caplin.integration.datasourcex.util.serialization.jackson3 + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import tools.jackson.core.JsonGenerator +import tools.jackson.databind.SerializationContext +import tools.jackson.databind.ser.std.StdSerializer + +internal class VersionedMapEventSerializer : + StdSerializer>(VersionedMapEvent::class.java) { + override fun serialize( + value: VersionedMapEvent<*, *>, + gen: JsonGenerator, + provider: SerializationContext, + ) { + gen.writeStartObject() + when (value) { + is VersionedMapEvent.Upsert -> { + gen.writeStringProperty("type", "upsert") + gen.writeName("key") + provider.writeValue(gen, value.key) + gen.writeName("value") + provider.writeValue(gen, value.value) + gen.writeNumberProperty("version", value.version) + } + is VersionedMapEvent.Removed -> { + gen.writeStringProperty("type", "removed") + gen.writeName("key") + provider.writeValue(gen, value.key) + gen.writeNumberProperty("version", value.version) + } + } + gen.writeEndObject() + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt new file mode 100644 index 0000000..70a1c25 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -0,0 +1,61 @@ +package com.caplin.integration.datasourcex.util.store + +import com.caplin.integration.datasourcex.util.cache.SuspendingCache +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onSubscription + +/** + * Shared core for the store-backed [FlowStore] variants: a delta-only [signal] bus, a bounded hot + * set [cache] of [Versioned] values, and read-through on a miss. The cache holds versions so that + * each entry's freshness can be compared against incoming deltas. + */ +internal abstract class AbstractFlowStore( + protected val loader: CacheLoader, + protected val cache: SuspendingCache>, +) : FlowStore { + + protected val signal = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + + override fun asFlow(): Flow> = signal.asSharedFlow() + + override suspend fun get(key: K): V? = cache.getIfPresent(key)?.value ?: loadAndCache(key)?.value + + /** Loads [key] from the store and populates the cache. */ + protected open suspend fun loadAndCache(key: K): Versioned? = + loader.load(key)?.also { cache.put(key, it) } + + override fun valueFlow(key: K): Flow = + flow { + var highest = Long.MIN_VALUE + signal + .onSubscription { + val initial = loadAndCache(key) + highest = initial?.version ?: Long.MIN_VALUE + this@flow.emit(initial?.value) + } + .collect { event -> + if (event.key == key && event.version > highest) { + highest = event.version + this@flow.emit(event.valueOrNull()) + } + } + } + .distinctUntilChanged() +} + +internal fun VersionedMapEvent<*, V>.valueOrNull(): V? = + when (this) { + is VersionedMapEvent.Upsert -> value + is VersionedMapEvent.Removed -> null + } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt new file mode 100644 index 0000000..40bfdf8 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt @@ -0,0 +1,16 @@ +package com.caplin.integration.datasourcex.util.store + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +/** Read half of the store SPI. */ +interface CacheLoader { + suspend fun load(key: K): Versioned? + + suspend fun loadAll(keys: Collection): Map> = buildMap { + for (key in keys) load(key)?.let { put(key, it) } + } + + /** Streams every known key, for warm-up / enumeration rather than the hot path. */ + fun loadAllKeys(): Flow = emptyFlow() +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt new file mode 100644 index 0000000..6f3d43b --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt @@ -0,0 +1,4 @@ +package com.caplin.integration.datasourcex.util.store + +/** Combines [CacheLoader] and [CacheWriter] for a backend that implements both. */ +interface CacheLoaderWriter : CacheLoader, CacheWriter diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt new file mode 100644 index 0000000..b06611e --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt @@ -0,0 +1,19 @@ +package com.caplin.integration.datasourcex.util.store + +/** + * Write half of the store SPI. Implementations enlist on [TxContext.transaction] and must not + * commit the transaction themselves. + */ +interface CacheWriter { + suspend fun write(key: K, value: V, version: Long, tx: TxContext) + + suspend fun writeAll(entries: Map>, tx: TxContext) { + for ((key, versioned) in entries) write(key, versioned.value, versioned.version, tx) + } + + suspend fun delete(key: K, version: Long, tx: TxContext) + + suspend fun deleteAll(keys: Collection, version: Long, tx: TxContext) { + for (key in keys) delete(key, version, tx) + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt new file mode 100644 index 0000000..6b4c54f --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -0,0 +1,90 @@ +package com.caplin.integration.datasourcex.util.store + +import com.caplin.integration.datasourcex.util.cache.SuspendingCache +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch + +/** + * Read view of a store-backed map. Exposes the delta stream and per-key access only; there is no + * full-map snapshot, so values are read through to the store on a miss. + * + * The value [V] must be an **aggregate root**: the complete state owned under a key, treated as an + * opaque whole. Every write replaces a key's value entirely — there are no field-level or partial + * updates and no merge — each published [VersionedMapEvent.Upsert] carries the full value, and a + * cache miss reads the whole value back through the store. Together with single-writer-per-key + * ownership (exactly one process writes a given key) this is what makes a key's version sequence + * totally ordered. + * + * It is therefore unsuitable for values updated field-by-field by multiple writers, partial + * projections that must be merged, or values large enough that republishing the whole value on + * every change is too costly. + */ +interface FlowStore { + /** The live, delta-only stream of versioned mutations. */ + fun asFlow(): Flow> + + /** The latest value for [key], starting from a read-through load then following the stream. */ + fun valueFlow(key: K): Flow + + suspend fun get(key: K): V? +} + +/** + * Owning, read/write view of a store-backed map. Writes are written through [CacheWriter] and the + * cache update and delta are applied only when the enclosing transaction commits. [V] must be an + * aggregate root — see [FlowStore]. + */ +interface MutableFlowStore : FlowStore { + suspend fun put(key: K, value: V) + + suspend fun put(key: K, value: V, tx: TxContext) + + suspend fun putAll(from: Map) + + suspend fun putAll(from: Map, tx: TxContext) + + suspend fun remove(key: K) + + suspend fun remove(key: K, tx: TxContext) +} + +/** + * Creates a read-only [FlowStore] consumer fed by an [inbound] delta stream (the owner's published + * mutations) and reading [loader] on a cache miss. The collection of [inbound] is launched in + * [scope]. [V] must be an aggregate root — see [FlowStore]. + */ +fun flowStore( + loader: CacheLoader, + inbound: Flow>, + cache: SuspendingCache>, + scope: CoroutineScope, +): FlowStore = FlowStoreImpl(loader, cache, inbound, scope) + +internal class FlowStoreImpl( + loader: CacheLoader, + cache: SuspendingCache>, + inbound: Flow>, + scope: CoroutineScope, +) : AbstractFlowStore(loader, cache) { + + init { + scope.launch { + inbound.collect { event -> + // Keep a resident entry coherent, gating on the version so a delta older than a + // read-through load is dropped. Non-resident keys are left for the next read-through. + val cached = cache.asyncCache().getIfPresent(event.key)?.await() + if (cached != null && event.version > cached.version) { + when (event) { + is VersionedMapEvent.Upsert -> + cache.put(event.key, Versioned(event.value, event.version)) + is VersionedMapEvent.Removed -> cache.invalidate(event.key) + } + } + signal.emit(event) + } + } + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt new file mode 100644 index 0000000..893b9c1 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -0,0 +1,73 @@ +package com.caplin.integration.datasourcex.util.store + +import com.caplin.integration.datasourcex.util.cache.SuspendingCache +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent + +/** + * Creates a [MutableFlowStore] backed by [loader] / [writer] and the bounded hot set [cache]. [V] + * must be an aggregate root — see [FlowStore]. + */ +fun mutableFlowStore( + loader: CacheLoader, + writer: CacheWriter, + transactionRunner: TransactionRunner, + cache: SuspendingCache>, + versionSource: VersionSource = CountingVersionSource(), +): MutableFlowStore = + MutableFlowStoreImpl(loader, writer, transactionRunner, cache, versionSource) + +/** + * Creates a [MutableFlowStore] backed by a combined [CacheLoaderWriter] and the bounded hot set + * [cache]. [V] must be an aggregate root — see [FlowStore]. + */ +fun mutableFlowStore( + store: CacheLoaderWriter, + transactionRunner: TransactionRunner, + cache: SuspendingCache>, + versionSource: VersionSource = CountingVersionSource(), +): MutableFlowStore = + MutableFlowStoreImpl(store, store, transactionRunner, cache, versionSource) + +internal class MutableFlowStoreImpl( + loader: CacheLoader, + private val writer: CacheWriter, + private val transactionRunner: TransactionRunner, + cache: SuspendingCache>, + private val versionSource: VersionSource, +) : AbstractFlowStore(loader, cache), MutableFlowStore { + + override suspend fun put(key: K, value: V) = transactionRunner.run { put(key, value, it) } + + override suspend fun put(key: K, value: V, tx: TxContext) { + val version = versionSource.next() + writer.write(key, value, version, tx) + tx.onCommitEnd { + cache.put(key, Versioned(value, version)) + signal.emit(VersionedMapEvent.Upsert(key, value, version)) + } + } + + override suspend fun putAll(from: Map) = transactionRunner.run { putAll(from, it) } + + override suspend fun putAll(from: Map, tx: TxContext) { + val versioned = from.mapValues { (_, value) -> Versioned(value, versionSource.next()) } + writer.writeAll(versioned, tx) + tx.onCommitEnd { + versioned.forEach { (k, v) -> + cache.put(k, v) + signal.emit(VersionedMapEvent.Upsert(k, v.value, v.version)) + } + } + } + + override suspend fun remove(key: K) = transactionRunner.run { remove(key, it) } + + override suspend fun remove(key: K, tx: TxContext) { + val version = versionSource.next() + writer.delete(key, version, tx) + tx.onCommitEnd { + cache.invalidate(key) + signal.emit(VersionedMapEvent.Removed(key, version)) + } + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt new file mode 100644 index 0000000..dabbabc --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt @@ -0,0 +1,6 @@ +package com.caplin.integration.datasourcex.util.store + +/** Opens a unit of work, runs [block], then commits on success or rolls back on failure. */ +fun interface TransactionRunner { + suspend fun run(block: suspend (TxContext) -> Unit) +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt new file mode 100644 index 0000000..d498272 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt @@ -0,0 +1,42 @@ +package com.caplin.integration.datasourcex.util.store + +import java.util.concurrent.CopyOnWriteArrayList + +/** + * A driver-agnostic unit of work. [transaction] is the backend handle (jOOQ, JDBC, R2DBC, ...). The + * owner of the transaction begins and commits it; callers only register post-commit side-effects + * via [onCommitEnd] (and optional [onRollback] cleanup). + */ +interface TxContext { + val transaction: T + + fun onCommitEnd(action: suspend () -> Unit) + + fun onRollback(action: suspend () -> Unit) {} +} + +/** + * A [TxContext] that buffers registered actions and fires them when [commit] / [rollback] is + * invoked. Used for the non-transactional, auto-commit write path where the caller drives the + * single unit of work directly. + */ +class AutoCommitTxContext(override val transaction: T) : TxContext { + private val commitActions = CopyOnWriteArrayList Unit>() + private val rollbackActions = CopyOnWriteArrayList Unit>() + + override fun onCommitEnd(action: suspend () -> Unit) { + commitActions += action + } + + override fun onRollback(action: suspend () -> Unit) { + rollbackActions += action + } + + suspend fun commit() { + for (action in commitActions) action() + } + + suspend fun rollback() { + for (action in rollbackActions) action() + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt new file mode 100644 index 0000000..861c4cc --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt @@ -0,0 +1,30 @@ +package com.caplin.integration.datasourcex.util.store + +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** Supplies version tokens that are strictly greater than any previously returned. */ +fun interface VersionSource { + fun next(): Long +} + +/** A monotonic counter whose first [next] returns `start + 1`. */ +class CountingVersionSource(start: Long = 0L) : VersionSource { + private val counter = AtomicLong(start) + + override fun next(): Long = counter.incrementAndGet() +} + +/** + * Wall-clock micros, guarded with `next = max(prev + 1, now)` so values stay strictly increasing + * even when the clock stalls within a tick or regresses. + */ +class TimestampVersionSource( + private val nowMicros: () -> Long = { + TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()) + } +) : VersionSource { + private val last = AtomicLong(0L) + + override fun next(): Long = last.updateAndGet { prev -> maxOf(prev + 1, nowMicros()) } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Versioned.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Versioned.kt new file mode 100644 index 0000000..b625a75 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Versioned.kt @@ -0,0 +1,4 @@ +package com.caplin.integration.datasourcex.util.store + +/** A [value] paired with the monotonically increasing [version] it was written at. */ +data class Versioned(val value: V, val version: Long) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt new file mode 100644 index 0000000..a6bf5b2 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt @@ -0,0 +1,88 @@ +package com.caplin.integration.datasourcex.util.cache + +import com.github.benmanes.caffeine.cache.Caffeine +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope + +// Real concurrency / real dispatchers, so these opt out of the project's coroutineTestScope. +class SuspendingCacheTest : + FunSpec({ + val scope = CoroutineScope(Dispatchers.Default) + afterSpec { scope.cancel() } + + test("concurrent gets for the same key share a single load").config( + coroutineTestScope = false + ) { + val calls = AtomicInteger(0) + val gate = CompletableDeferred() + val cache = + Caffeine.newBuilder().buildSuspending(scope) { key -> + calls.incrementAndGet() + gate.await() + "v:$key" + } + + coroutineScope { + val gets = List(20) { async { cache.get("k") } } + gate.complete(Unit) + gets.awaitAll().forEach { it shouldBe "v:k" } + } + + calls.get() shouldBe 1 + } + + test("eviction triggers a read-through reload").config(coroutineTestScope = false) { + val calls = ConcurrentHashMap() + val cache = + Caffeine.newBuilder().maximumSize(1).buildSuspending(scope) { key -> + calls.merge(key, 1, Int::plus) + "v:$key" + } + + cache.get("a") shouldBe "v:a" + cache.get("b") shouldBe "v:b" + cache.asyncCache().synchronous().cleanUp() + + cache.getIfPresent("a").shouldBeNull() + cache.get("a") shouldBe "v:a" + + calls["a"] shouldBe 2 + } + + test("a failed load surfaces to the caller without breaking the cache").config( + coroutineTestScope = false + ) { + val cache = + Caffeine.newBuilder().buildSuspending(scope) { key -> + if (key == "bad") error("boom") else "v:$key" + } + + shouldThrow { cache.get("bad") } + cache.get("good") shouldBe "v:good" + } + + test("put populates without invoking the loader").config(coroutineTestScope = false) { + val calls = AtomicInteger(0) + val cache = + Caffeine.newBuilder().buildSuspending(scope) { key -> + calls.incrementAndGet() + "loaded:$key" + } + + cache.put("k", "put") + cache.get("k") shouldBe "put" + + calls.get() shouldBe 0 + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializationTest.kt new file mode 100644 index 0000000..ca2e435 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/fory/VersionedMapEventSerializationTest.kt @@ -0,0 +1,31 @@ +package com.caplin.integration.datasourcex.util.serialization.fory + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.apache.fory.Fory +import org.apache.fory.config.Language + +class VersionedMapEventSerializationTest : + FunSpec({ + val fory = + Fory.builder() + .withLanguage(Language.JAVA) + .requireClassRegistration(false) + .build() + .registerDataSourceSerializers() + + test("Upsert") { + val event = VersionedMapEvent.Upsert("key", "value", 7L) + val bytes = fory.serialize(event) + val deserialized = fory.deserialize(bytes) + deserialized shouldBe event + } + + test("Removed") { + val event = VersionedMapEvent.Removed("key", 9L) + val bytes = fory.serialize(event) + val deserialized = fory.deserialize(bytes) + deserialized shouldBe event + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt new file mode 100644 index 0000000..2849e5c --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt @@ -0,0 +1,29 @@ +package com.caplin.integration.datasourcex.util.serialization.jackson2 + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class VersionedMapEventSerializationTest : + FunSpec({ + val mapper: ObjectMapper = jacksonObjectMapper().registerDataSourceModule() + + test("Upsert") { + val event: VersionedMapEvent = VersionedMapEvent.Upsert("key", "value", 7L) + val json = mapper.writeValueAsString(event) + val deserialized = + mapper.readValue(json, object : TypeReference>() {}) + deserialized shouldBe event + } + + test("Removed") { + val event: VersionedMapEvent = VersionedMapEvent.Removed("key", 9L) + val json = mapper.writeValueAsString(event) + val deserialized = + mapper.readValue(json, object : TypeReference>() {}) + deserialized shouldBe event + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt new file mode 100644 index 0000000..1f87480 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt @@ -0,0 +1,28 @@ +package com.caplin.integration.datasourcex.util.serialization.jackson3 + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import tools.jackson.core.type.TypeReference +import tools.jackson.module.kotlin.jacksonMapperBuilder + +class VersionedMapEventSerializationTest : + FunSpec({ + val mapper = jacksonMapperBuilder().addDataSourceModule().build() + + test("Upsert") { + val event: VersionedMapEvent = VersionedMapEvent.Upsert("key", "value", 7L) + val json = mapper.writeValueAsString(event) + val deserialized = + mapper.readValue(json, object : TypeReference>() {}) + deserialized shouldBe event + } + + test("Removed") { + val event: VersionedMapEvent = VersionedMapEvent.Removed("key", 9L) + val json = mapper.writeValueAsString(event) + val deserialized = + mapper.readValue(json, object : TypeReference>() {}) + deserialized shouldBe event + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt new file mode 100644 index 0000000..4e10438 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -0,0 +1,99 @@ +package com.caplin.integration.datasourcex.util.store + +import app.cash.turbine.test +import com.caplin.integration.datasourcex.util.cache.buildSuspending +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.github.benmanes.caffeine.cache.Caffeine +import io.kotest.core.spec.style.FunSpec +import io.kotest.engine.coroutines.backgroundScope +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow + +class FlowStoreTest : + FunSpec({ + test("get reads through to the store on a miss") { + val store = InMemoryCacheLoaderWriter() + store.write("k", "seeded", 1L, AutoCommitTxContext(Unit)) + val consumer = + flowStore( + store, + emptyFlow(), + Caffeine.newBuilder().buildSuspending(backgroundScope), + backgroundScope, + ) + + consumer.get("k") shouldBe "seeded" + consumer.get("missing").shouldBeNull() + } + + test("a delta older than a read-through load is dropped; a newer one is applied") { + val store = InMemoryCacheLoaderWriter() + store.write("k", "v5", 5L, AutoCommitTxContext(Unit)) + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val consumer = flowStore(store, inbound, cache, backgroundScope) + delay(1.milliseconds) + + consumer.get("k") shouldBe "v5" + + inbound.emit(VersionedMapEvent.Upsert("k", "stale", 3L)) + delay(1.milliseconds) + consumer.get("k") shouldBe "v5" + + inbound.emit(VersionedMapEvent.Upsert("k", "v9", 9L)) + delay(1.milliseconds) + consumer.get("k") shouldBe "v9" + } + + test("an equal-version delta after a read-through is gated out") { + val store = InMemoryCacheLoaderWriter() + store.write("k", "v2", 2L, AutoCommitTxContext(Unit)) + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val consumer = flowStore(store, inbound, cache, backgroundScope) + delay(1.milliseconds) + + consumer.get("k") shouldBe "v2" + + inbound.emit(VersionedMapEvent.Upsert("k", "wrong", 2L)) + delay(1.milliseconds) + consumer.get("k") shouldBe "v2" + } + + test("consumer converges to the owner's state through the delta stream") { + val store = InMemoryCacheLoaderWriter() + val owner = + mutableFlowStore( + store, + inMemoryTransactionRunner, + Caffeine.newBuilder().buildSuspending(backgroundScope), + ) + val consumer = + flowStore( + store, + owner.asFlow(), + Caffeine.newBuilder().buildSuspending(backgroundScope), + backgroundScope, + ) + + consumer.valueFlow("k").test { + delay(1.milliseconds) + awaitItem().shouldBeNull() + + owner.put("k", "v1") + awaitItem() shouldBe "v1" + + owner.put("k", "v2") + awaitItem() shouldBe "v2" + + owner.remove("k") + awaitItem().shouldBeNull() + } + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt new file mode 100644 index 0000000..668e7b6 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt @@ -0,0 +1,39 @@ +package com.caplin.integration.datasourcex.util.store + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow + +/** + * In-memory reference implementation of the store SPI for tests. Writes apply immediately to a + * backing [ConcurrentHashMap]; the [TxContext] (type [Unit]) is ignored. + */ +class InMemoryCacheLoaderWriter : CacheLoaderWriter { + private val backing = ConcurrentHashMap>() + + override suspend fun load(key: K): Versioned? = backing[key] + + override fun loadAllKeys(): Flow = backing.keys.toList().asFlow() + + override suspend fun write(key: K, value: V, version: Long, tx: TxContext) { + backing[key] = Versioned(value, version) + } + + override suspend fun delete(key: K, version: Long, tx: TxContext) { + backing.remove(key) + } +} + +/** + * A [TransactionRunner] over a [Unit] transaction that commits on success and rolls back on error. + */ +val inMemoryTransactionRunner = TransactionRunner { block -> + val tx = AutoCommitTxContext(Unit) + try { + block(tx) + tx.commit() + } catch (e: Throwable) { + tx.rollback() + throw e + } +} diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt new file mode 100644 index 0000000..c7cf33c --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt @@ -0,0 +1,36 @@ +package com.caplin.integration.datasourcex.util.store + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.toList + +class InMemoryCacheLoaderWriterTest : + FunSpec({ + test("writes are loadable and deletes remove the entry") { + val store = InMemoryCacheLoaderWriter() + val tx = AutoCommitTxContext(Unit) + + store.write("a", "A", 1L, tx) + store.write("b", "B", 2L, tx) + + store.load("a") shouldBe Versioned("A", 1L) + store.loadAll(listOf("a", "b", "missing")) shouldBe + mapOf("a" to Versioned("A", 1L), "b" to Versioned("B", 2L)) + store.loadAllKeys().toList().toSet() shouldBe setOf("a", "b") + + store.delete("a", 3L, tx) + store.load("a").shouldBeNull() + } + + test("AutoCommitTxContext fires commit actions and skips rollback actions") { + val tx = AutoCommitTxContext(Unit) + val log = mutableListOf() + tx.onCommitEnd { log += "commit" } + tx.onRollback { log += "rollback" } + + tx.commit() + + log shouldBe listOf("commit") + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt new file mode 100644 index 0000000..563d341 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -0,0 +1,83 @@ +package com.caplin.integration.datasourcex.util.store + +import app.cash.turbine.test +import com.caplin.integration.datasourcex.util.cache.buildSuspending +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.github.benmanes.caffeine.cache.Caffeine +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.engine.coroutines.backgroundScope +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.mockk + +class MutableFlowStoreTest : + FunSpec({ + test("put publishes a versioned delta and updates the cache only on commit") { + val backing = InMemoryCacheLoaderWriter() + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + + store.asFlow().test { + val tx = AutoCommitTxContext(Unit) + store.put("k", "v", tx) + expectNoEvents() + cache.getIfPresent("k").shouldBeNull() + + tx.commit() + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v", 1L) + cache.getIfPresent("k") shouldBe Versioned("v", 1L) + } + } + + test("rollback publishes nothing and the next write gets a strictly greater version") { + val backing = InMemoryCacheLoaderWriter() + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + + store.asFlow().test { + val tx = AutoCommitTxContext(Unit) + store.put("k", "v1", tx) + tx.rollback() + expectNoEvents() + + store.put("k", "v2") + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v2", 2L) + } + } + + test("a writer failure propagates and leaves the cache and stream untouched") { + val writer = mockk>() + coEvery { writer.write(any(), any(), any(), any()) } throws RuntimeException("boom") + val backing = InMemoryCacheLoaderWriter() + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val store = mutableFlowStore(backing, writer, inMemoryTransactionRunner, cache) + + store.asFlow().test { + shouldThrow { store.put("k", "v") } + expectNoEvents() + cache.getIfPresent("k").shouldBeNull() + } + } + + test("get reflects the committed value only after commit") { + val backing = InMemoryCacheLoaderWriter() + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + + store.put("k", "v1") + store.get("k") shouldBe "v1" + + val tx = AutoCommitTxContext(Unit) + store.put("k", "v2", tx) + store.get("k") shouldBe "v1" + + tx.commit() + store.get("k") shouldBe "v2" + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt new file mode 100644 index 0000000..92cb72f --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt @@ -0,0 +1,30 @@ +package com.caplin.integration.datasourcex.util.store + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import java.util.concurrent.atomic.AtomicLong + +class VersionSourceTest : + FunSpec({ + test("CountingVersionSource is strictly increasing from start + 1") { + val source = CountingVersionSource(start = 100L) + + List(5) { source.next() } shouldBe listOf(101L, 102L, 103L, 104L, 105L) + } + + test("TimestampVersionSource bumps past a stalled clock") { + val source = TimestampVersionSource(nowMicros = { 1_000L }) + + List(4) { source.next() } shouldBe listOf(1_000L, 1_001L, 1_002L, 1_003L) + } + + test("TimestampVersionSource stays strictly increasing under a regressing clock") { + val clock = AtomicLong(10_000L) + val source = TimestampVersionSource(nowMicros = { clock.getAndAdd(-100L) }) + + val seq = List(50) { source.next() } + + seq.zipWithNext().forEach { (a, b) -> b shouldBeGreaterThan a } + } + }) From bfe4676d4c87ee6717966114a3a6cd1f8dafaff5 Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Tue, 2 Jun 2026 17:10:07 +0100 Subject: [PATCH 02/13] Address store-backed FlowStore review findings Source versions from the writer (DB), not an app-side VersionSource: the version is now commit-ordered and durable, fixing same-key write ordering and removing the in-memory counter that reset on restart. CacheWriter.write/delete return the assigned version; VersionSource and its implementations are deleted. - All cache writes are version-gated via Caffeine's per-key atomic compute (lock-free), so a read-through, owner commit, or out-of-order delta cannot clobber a newer entry. Owner commits emit the delta before refreshing the cache to close a cancellation gap. - Add tombstones (CacheEntry: Live/Tombstone) so a removal cannot be resurrected by a stale older read-through. - Bound the signal SharedFlow buffer (default 256, tunable) instead of Int.MAX_VALUE. - Reject null/non-integral version in both Jackson deserializers; document the String/JSON-native key/value round-trip limitation. - AutoCommitTxContext is one-shot (reuse throws; rollback-after-commit no-ops). - deleteAll versions per key; document loadAll/loadAllKeys defaults; delegate the CacheLoaderWriter factory overload. - De-flake FlowStoreTest (subscription synchronisation instead of delays); add tombstone, version-parsing, and tx-guard coverage. --- util/api/datasourcex-util.api | 63 +++++++++------ .../jackson2/VersionedMapEventDeserializer.kt | 12 ++- .../jackson3/VersionedMapEventDeserializer.kt | 12 ++- .../util/store/AbstractFlowStore.kt | 51 +++++++++--- .../datasourcex/util/store/CacheEntry.kt | 13 +++ .../datasourcex/util/store/CacheLoader.kt | 6 +- .../datasourcex/util/store/CacheWriter.kt | 20 +++-- .../datasourcex/util/store/FlowStore.kt | 50 +++++++----- .../util/store/MutableFlowStore.kt | 45 +++++------ .../datasourcex/util/store/TxContext.kt | 8 ++ .../datasourcex/util/store/VersionSource.kt | 30 ------- .../VersionedMapEventSerializationTest.kt | 16 ++++ .../VersionedMapEventSerializationTest.kt | 16 ++++ .../datasourcex/util/store/FlowStoreTest.kt | 79 +++++++++++++------ .../util/store/InMemoryCacheLoaderWriter.kt | 19 ++++- .../store/InMemoryCacheLoaderWriterTest.kt | 6 +- .../util/store/MutableFlowStoreTest.kt | 29 +++++-- .../datasourcex/util/store/TxContextTest.kt | 29 +++++++ .../util/store/VersionSourceTest.kt | 30 ------- 19 files changed, 353 insertions(+), 181 deletions(-) create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt delete mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt delete mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index 915139d..00e024c 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -579,6 +579,10 @@ public final class com/caplin/integration/datasourcex/util/store/AutoCommitTxCon public final fun rollback (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class com/caplin/integration/datasourcex/util/store/CacheEntry { + public abstract fun getVersion ()J +} + public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoader { public abstract fun load (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun loadAll (Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -594,31 +598,24 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Ca } public final class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter$DefaultImpls { - public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun loadAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun loadAllKeys (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;)Lkotlinx/coroutines/flow/Flow; public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class com/caplin/integration/datasourcex/util/store/CacheWriter { - public abstract fun delete (Ljava/lang/Object;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun deleteAll (Ljava/util/Collection;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun delete (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteAll (Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun writeAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/caplin/integration/datasourcex/util/store/CacheWriter$DefaultImpls { - public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Collection;JLcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public final class com/caplin/integration/datasourcex/util/store/CountingVersionSource : com/caplin/integration/datasourcex/util/store/VersionSource { - public fun ()V - public fun (J)V - public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun next ()J -} - public abstract interface class com/caplin/integration/datasourcex/util/store/FlowStore { public abstract fun asFlow ()Lkotlinx/coroutines/flow/Flow; public abstract fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -626,7 +623,21 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Fl } public final class com/caplin/integration/datasourcex/util/store/FlowStoreKt { - public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlinx/coroutines/CoroutineScope;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlinx/coroutines/CoroutineScope;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlinx/coroutines/CoroutineScope;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; +} + +public final class com/caplin/integration/datasourcex/util/store/Live : com/caplin/integration/datasourcex/util/store/CacheEntry { + public fun (Ljava/lang/Object;J)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()J + public final fun copy (Ljava/lang/Object;J)Lcom/caplin/integration/datasourcex/util/store/Live; + public static synthetic fun copy$default (Lcom/caplin/integration/datasourcex/util/store/Live;Ljava/lang/Object;JILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Live; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun getVersion ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public abstract interface class com/caplin/integration/datasourcex/util/store/MutableFlowStore : com/caplin/integration/datasourcex/util/store/FlowStore { @@ -639,17 +650,21 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Mu } public final class com/caplin/integration/datasourcex/util/store/MutableFlowStoreKt { - public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; - public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; - public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;ILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; - public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lcom/caplin/integration/datasourcex/util/store/VersionSource;ILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;I)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;I)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; } -public final class com/caplin/integration/datasourcex/util/store/TimestampVersionSource : com/caplin/integration/datasourcex/util/store/VersionSource { - public fun ()V - public fun (Lkotlin/jvm/functions/Function0;)V - public synthetic fun (Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun next ()J +public final class com/caplin/integration/datasourcex/util/store/Tombstone : com/caplin/integration/datasourcex/util/store/CacheEntry { + public fun (J)V + public final fun component1 ()J + public final fun copy (J)Lcom/caplin/integration/datasourcex/util/store/Tombstone; + public static synthetic fun copy$default (Lcom/caplin/integration/datasourcex/util/store/Tombstone;JILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Tombstone; + public fun equals (Ljava/lang/Object;)Z + public fun getVersion ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public abstract interface class com/caplin/integration/datasourcex/util/store/TransactionRunner { @@ -666,10 +681,6 @@ public final class com/caplin/integration/datasourcex/util/store/TxContext$Defau public static fun onRollback (Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/jvm/functions/Function1;)V } -public abstract interface class com/caplin/integration/datasourcex/util/store/VersionSource { - public abstract fun next ()J -} - public final class com/caplin/integration/datasourcex/util/store/Versioned { public fun (Ljava/lang/Object;J)V public final fun component1 ()Ljava/lang/Object; diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt index d50f0f1..bae4a4f 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventDeserializer.kt @@ -7,6 +7,11 @@ import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.node.ObjectNode +/** + * Keys and values round-trip as JSON-native types only: a String key returns a String, but a + * numeric key comes back as Integer/Long and a structured value as a Map, not its original type. + * Intended for String (or otherwise JSON-native) keys and values. + */ internal class VersionedMapEventDeserializer : StdDeserializer>(VersionedMapEvent::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): VersionedMapEvent<*, *> { @@ -18,8 +23,11 @@ internal class VersionedMapEventDeserializer : node.get("key")?.let { p.codec.treeToValue(it, Any::class.java) } ?: throw JsonMappingException.from(p, "Missing key field for VersionedMapEvent") val version = - node.get("version")?.asLong() - ?: throw JsonMappingException.from(p, "Missing version field for VersionedMapEvent") + node.get("version")?.takeIf { it.isIntegralNumber }?.asLong() + ?: throw JsonMappingException.from( + p, + "Missing or non-integral version field for VersionedMapEvent", + ) return when (type) { "upsert" -> { val value = diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt index cfa2e09..d2d979d 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventDeserializer.kt @@ -6,6 +6,11 @@ import tools.jackson.databind.DatabindException import tools.jackson.databind.DeserializationContext import tools.jackson.databind.deser.std.StdDeserializer +/** + * Keys and values round-trip as JSON-native types only: a String key returns a String, but a + * numeric key comes back as Integer/Long and a structured value as a Map, not its original type. + * Intended for String (or otherwise JSON-native) keys and values. + */ internal class VersionedMapEventDeserializer : StdDeserializer>(VersionedMapEvent::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): VersionedMapEvent<*, *> { @@ -17,8 +22,11 @@ internal class VersionedMapEventDeserializer : node.get("key")?.let { ctxt.readTreeAsValue(it, Any::class.java) } ?: throw DatabindException.from(p, "Missing key field for VersionedMapEvent") val version = - node.get("version")?.asLong() - ?: throw DatabindException.from(p, "Missing version field for VersionedMapEvent") + node.get("version")?.takeIf { it.isIntegralNumber }?.asLong() + ?: throw DatabindException.from( + p, + "Missing or non-integral version field for VersionedMapEvent", + ) return when (type) { "upsert" -> { val value = diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt index 70a1c25..4b28178 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -2,6 +2,7 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import java.util.concurrent.CompletableFuture import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -10,30 +11,56 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onSubscription +internal const val DEFAULT_SIGNAL_BUFFER: Int = 256 + /** * Shared core for the store-backed [FlowStore] variants: a delta-only [signal] bus, a bounded hot - * set [cache] of [Versioned] values, and read-through on a miss. The cache holds versions so that - * each entry's freshness can be compared against incoming deltas. + * set [cache] of [CacheEntry] values, and read-through on a miss. Entries carry versions so each + * entry's freshness can be compared against incoming deltas and concurrent read-through loads. */ internal abstract class AbstractFlowStore( protected val loader: CacheLoader, - protected val cache: SuspendingCache>, + protected val cache: SuspendingCache>, + bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, ) : FlowStore { protected val signal = MutableSharedFlow>( replay = 0, - extraBufferCapacity = Int.MAX_VALUE, + extraBufferCapacity = bufferCapacity, onBufferOverflow = BufferOverflow.SUSPEND, ) override fun asFlow(): Flow> = signal.asSharedFlow() - override suspend fun get(key: K): V? = cache.getIfPresent(key)?.value ?: loadAndCache(key)?.value + override suspend fun get(key: K): V? = + (cache.getIfPresent(key) ?: loadAndCache(key))?.valueOrNull() + + /** + * Loads [key] through the store and caches it, but never regresses a fresher resident entry: a + * delta stream can legitimately run ahead of the store a read-through reads from. A resident + * [Tombstone] beats a null load, so a removed key is not re-read as absent. + */ + protected open suspend fun loadAndCache(key: K): CacheEntry? = + loader.load(key)?.let { cachePutIfNewer(key, Live(it.value, it.version)) } + ?: cache.getIfPresent(key) - /** Loads [key] from the store and populates the cache. */ - protected open suspend fun loadAndCache(key: K): Versioned? = - loader.load(key)?.also { cache.put(key, it) } + /** + * Atomically caches [candidate] unless a strictly-newer entry is already resident, returning the + * resident entry afterwards. The cache's per-key atomic compute closes the check-then-put race, + * so a concurrent writer (commit, read-through, or inbound delta) cannot be clobbered by a stale + * read. + */ + protected fun cachePutIfNewer(key: K, candidate: CacheEntry): CacheEntry { + val resident = + cache.asyncCache().asMap().merge(key, CompletableFuture.completedFuture(candidate)) { + oldFuture, + newFuture -> + val old = oldFuture.getNow(null) + if (old != null && old.version >= candidate.version) oldFuture else newFuture + } + return resident?.getNow(candidate) ?: candidate + } override fun valueFlow(key: K): Flow = flow { @@ -42,7 +69,7 @@ internal abstract class AbstractFlowStore( .onSubscription { val initial = loadAndCache(key) highest = initial?.version ?: Long.MIN_VALUE - this@flow.emit(initial?.value) + this@flow.emit(initial?.valueOrNull()) } .collect { event -> if (event.key == key && event.version > highest) { @@ -54,6 +81,12 @@ internal abstract class AbstractFlowStore( .distinctUntilChanged() } +private fun CacheEntry.valueOrNull(): V? = + when (this) { + is Live -> value + is Tombstone -> null + } + internal fun VersionedMapEvent<*, V>.valueOrNull(): V? = when (this) { is VersionedMapEvent.Upsert -> value diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt new file mode 100644 index 0000000..7322095 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt @@ -0,0 +1,13 @@ +package com.caplin.integration.datasourcex.util.store + +/** + * A versioned cache entry. [Live] holds a present value; [Tombstone] marks a removed key so a + * stale, older read-through is rejected by version instead of silently repopulating the value. + */ +sealed interface CacheEntry { + val version: Long +} + +data class Live(val value: V, override val version: Long) : CacheEntry + +data class Tombstone(override val version: Long) : CacheEntry diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt index 40bfdf8..6ad9acf 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt @@ -7,10 +7,14 @@ import kotlinx.coroutines.flow.emptyFlow interface CacheLoader { suspend fun load(key: K): Versioned? + /** Loads many keys. The default issues one sequential [load] per key; override to batch. */ suspend fun loadAll(keys: Collection): Map> = buildMap { for (key in keys) load(key)?.let { put(key, it) } } - /** Streams every known key, for warm-up / enumeration rather than the hot path. */ + /** + * Streams every known key, for warm-up / enumeration rather than the hot path. The default + * enumerates nothing; override to support warm-up. + */ fun loadAllKeys(): Flow = emptyFlow() } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt index b06611e..7abdfca 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt @@ -2,18 +2,24 @@ package com.caplin.integration.datasourcex.util.store /** * Write half of the store SPI. Implementations enlist on [TxContext.transaction] and must not - * commit the transaction themselves. + * commit the transaction themselves. Each write assigns and returns the new version: the version is + * the store's commit order (a sequence, identity, or version column), never supplied by the caller, + * so it survives restarts and orders writes the way the store committed them. */ interface CacheWriter { - suspend fun write(key: K, value: V, version: Long, tx: TxContext) + /** Writes [value] for [key] and returns the version the store assigned it. */ + suspend fun write(key: K, value: V, tx: TxContext): Long - suspend fun writeAll(entries: Map>, tx: TxContext) { - for ((key, versioned) in entries) write(key, versioned.value, versioned.version, tx) + /** Writes many entries, returning the version assigned to each. */ + suspend fun writeAll(values: Map, tx: TxContext): Map = buildMap { + for ((key, value) in values) put(key, write(key, value, tx)) } - suspend fun delete(key: K, version: Long, tx: TxContext) + /** Deletes [key] and returns the version the store assigned the removal. */ + suspend fun delete(key: K, tx: TxContext): Long - suspend fun deleteAll(keys: Collection, version: Long, tx: TxContext) { - for (key in keys) delete(key, version, tx) + /** Deletes many keys, returning the version assigned to each removal. */ + suspend fun deleteAll(keys: Collection, tx: TxContext): Map = buildMap { + for (key in keys) put(key, delete(key, tx)) } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index 6b4c54f..ed64919 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -2,9 +2,9 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import java.util.concurrent.CompletableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.future.await import kotlinx.coroutines.launch /** @@ -15,8 +15,8 @@ import kotlinx.coroutines.launch * opaque whole. Every write replaces a key's value entirely — there are no field-level or partial * updates and no merge — each published [VersionedMapEvent.Upsert] carries the full value, and a * cache miss reads the whole value back through the store. Together with single-writer-per-key - * ownership (exactly one process writes a given key) this is what makes a key's version sequence - * totally ordered. + * ownership (exactly one process writes a given key, and it serialises writes to that key) this is + * what makes a key's version sequence totally ordered. * * It is therefore unsuitable for values updated field-by-field by multiple writers, partial * projections that must be merged, or values large enough that republishing the whole value on @@ -33,9 +33,14 @@ interface FlowStore { } /** - * Owning, read/write view of a store-backed map. Writes are written through [CacheWriter] and the - * cache update and delta are applied only when the enclosing transaction commits. [V] must be an - * aggregate root — see [FlowStore]. + * Owning, read/write view of a store-backed map. Writes are written through [CacheWriter], which + * assigns each write its version, and the cache update and delta are published only when the + * enclosing transaction commits. + * + * The owner must **serialise writes to a given key** (single-writer-per-key): the version is the + * store's commit order, so concurrent unserialised writes to the same key would let the persisted + * row, not just the cache, settle on the wrong version. [V] must be an aggregate root — see + * [FlowStore]. */ interface MutableFlowStore : FlowStore { suspend fun put(key: K, value: V) @@ -59,28 +64,37 @@ interface MutableFlowStore : FlowStore { fun flowStore( loader: CacheLoader, inbound: Flow>, - cache: SuspendingCache>, + cache: SuspendingCache>, scope: CoroutineScope, -): FlowStore = FlowStoreImpl(loader, cache, inbound, scope) + bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, +): FlowStore = FlowStoreImpl(loader, cache, inbound, scope, bufferCapacity) internal class FlowStoreImpl( loader: CacheLoader, - cache: SuspendingCache>, + cache: SuspendingCache>, inbound: Flow>, scope: CoroutineScope, -) : AbstractFlowStore(loader, cache) { + bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, +) : AbstractFlowStore(loader, cache, bufferCapacity) { init { scope.launch { inbound.collect { event -> - // Keep a resident entry coherent, gating on the version so a delta older than a - // read-through load is dropped. Non-resident keys are left for the next read-through. - val cached = cache.asyncCache().getIfPresent(event.key)?.await() - if (cached != null && event.version > cached.version) { - when (event) { - is VersionedMapEvent.Upsert -> - cache.put(event.key, Versioned(event.value, event.version)) - is VersionedMapEvent.Removed -> cache.invalidate(event.key) + // Keep a resident entry coherent, gating on version with the cache's per-key atomic compute + // so neither a concurrent read-through nor an out-of-order delta can clobber a newer value. + // A removal leaves a tombstone so a stale older read-through is rejected by version; + // non-resident keys are left for the next read-through. + cache.asyncCache().asMap().computeIfPresent(event.key) { _, oldFuture -> + val old = oldFuture.getNow(null) + if (old == null || event.version <= old.version) { + oldFuture + } else { + val replacement: CacheEntry = + when (event) { + is VersionedMapEvent.Upsert -> Live(event.value, event.version) + is VersionedMapEvent.Removed -> Tombstone(event.version) + } + CompletableFuture.completedFuture(replacement) } } signal.emit(event) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index 893b9c1..23d9228 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -4,17 +4,17 @@ import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent /** - * Creates a [MutableFlowStore] backed by [loader] / [writer] and the bounded hot set [cache]. [V] - * must be an aggregate root — see [FlowStore]. + * Creates a [MutableFlowStore] backed by [loader] / [writer] and the bounded hot set [cache]. The + * [writer] assigns each write's version. [V] must be an aggregate root — see [FlowStore]. */ fun mutableFlowStore( loader: CacheLoader, writer: CacheWriter, transactionRunner: TransactionRunner, - cache: SuspendingCache>, - versionSource: VersionSource = CountingVersionSource(), + cache: SuspendingCache>, + bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, ): MutableFlowStore = - MutableFlowStoreImpl(loader, writer, transactionRunner, cache, versionSource) + MutableFlowStoreImpl(loader, writer, transactionRunner, cache, bufferCapacity) /** * Creates a [MutableFlowStore] backed by a combined [CacheLoaderWriter] and the bounded hot set @@ -23,39 +23,41 @@ fun mutableFlowStore( fun mutableFlowStore( store: CacheLoaderWriter, transactionRunner: TransactionRunner, - cache: SuspendingCache>, - versionSource: VersionSource = CountingVersionSource(), + cache: SuspendingCache>, + bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, ): MutableFlowStore = - MutableFlowStoreImpl(store, store, transactionRunner, cache, versionSource) + mutableFlowStore(store, store, transactionRunner, cache, bufferCapacity) internal class MutableFlowStoreImpl( loader: CacheLoader, private val writer: CacheWriter, private val transactionRunner: TransactionRunner, - cache: SuspendingCache>, - private val versionSource: VersionSource, -) : AbstractFlowStore(loader, cache), MutableFlowStore { + cache: SuspendingCache>, + bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, +) : AbstractFlowStore(loader, cache, bufferCapacity), MutableFlowStore { override suspend fun put(key: K, value: V) = transactionRunner.run { put(key, value, it) } override suspend fun put(key: K, value: V, tx: TxContext) { - val version = versionSource.next() - writer.write(key, value, version, tx) + val version = writer.write(key, value, tx) tx.onCommitEnd { - cache.put(key, Versioned(value, version)) + // Publish the delta first, then refresh the local cache: a cancellation between the two would + // otherwise leave the owner cache ahead of a delta no consumer ever sees. The cache write is + // version-gated so an interleaved post-commit reflection cannot regress it. signal.emit(VersionedMapEvent.Upsert(key, value, version)) + cachePutIfNewer(key, Live(value, version)) } } override suspend fun putAll(from: Map) = transactionRunner.run { putAll(from, it) } override suspend fun putAll(from: Map, tx: TxContext) { - val versioned = from.mapValues { (_, value) -> Versioned(value, versionSource.next()) } - writer.writeAll(versioned, tx) + val versions = writer.writeAll(from, tx) tx.onCommitEnd { - versioned.forEach { (k, v) -> - cache.put(k, v) - signal.emit(VersionedMapEvent.Upsert(k, v.value, v.version)) + versions.forEach { (key, version) -> + val value = from.getValue(key) + signal.emit(VersionedMapEvent.Upsert(key, value, version)) + cachePutIfNewer(key, Live(value, version)) } } } @@ -63,11 +65,10 @@ internal class MutableFlowStoreImpl( override suspend fun remove(key: K) = transactionRunner.run { remove(key, it) } override suspend fun remove(key: K, tx: TxContext) { - val version = versionSource.next() - writer.delete(key, version, tx) + val version = writer.delete(key, tx) tx.onCommitEnd { - cache.invalidate(key) signal.emit(VersionedMapEvent.Removed(key, version)) + cachePutIfNewer(key, Tombstone(version)) } } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt index d498272..9b8a2a7 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt @@ -1,6 +1,7 @@ package com.caplin.integration.datasourcex.util.store import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean /** * A driver-agnostic unit of work. [transaction] is the backend handle (jOOQ, JDBC, R2DBC, ...). The @@ -23,6 +24,7 @@ interface TxContext { class AutoCommitTxContext(override val transaction: T) : TxContext { private val commitActions = CopyOnWriteArrayList Unit>() private val rollbackActions = CopyOnWriteArrayList Unit>() + private val completed = AtomicBoolean(false) override fun onCommitEnd(action: suspend () -> Unit) { commitActions += action @@ -32,11 +34,17 @@ class AutoCommitTxContext(override val transaction: T) : TxContext { rollbackActions += action } + /** Fires the registered commit actions once; reusing a completed context throws. */ suspend fun commit() { + check(completed.compareAndSet(false, true)) { "Transaction already completed" } for (action in commitActions) action() } + /** + * Fires the registered rollback actions; a no-op once the context has committed or rolled back. + */ suspend fun rollback() { + if (!completed.compareAndSet(false, true)) return for (action in rollbackActions) action() } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt deleted file mode 100644 index 861c4cc..0000000 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/VersionSource.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.caplin.integration.datasourcex.util.store - -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong - -/** Supplies version tokens that are strictly greater than any previously returned. */ -fun interface VersionSource { - fun next(): Long -} - -/** A monotonic counter whose first [next] returns `start + 1`. */ -class CountingVersionSource(start: Long = 0L) : VersionSource { - private val counter = AtomicLong(start) - - override fun next(): Long = counter.incrementAndGet() -} - -/** - * Wall-clock micros, guarded with `next = max(prev + 1, now)` so values stay strictly increasing - * even when the clock stalls within a tick or regresses. - */ -class TimestampVersionSource( - private val nowMicros: () -> Long = { - TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()) - } -) : VersionSource { - private val last = AtomicLong(0L) - - override fun next(): Long = last.updateAndGet { prev -> maxOf(prev + 1, nowMicros()) } -} diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt index 2849e5c..988a18e 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson2/VersionedMapEventSerializationTest.kt @@ -2,8 +2,10 @@ package com.caplin.integration.datasourcex.util.serialization.jackson2 import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -26,4 +28,18 @@ class VersionedMapEventSerializationTest : mapper.readValue(json, object : TypeReference>() {}) deserialized shouldBe event } + + test("a non-integral version is rejected") { + val json = """{"type":"upsert","key":"key","value":"value","version":"7"}""" + shouldThrow { + mapper.readValue(json, object : TypeReference>() {}) + } + } + + test("an explicit-null version is rejected") { + val json = """{"type":"upsert","key":"key","value":"value","version":null}""" + shouldThrow { + mapper.readValue(json, object : TypeReference>() {}) + } + } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt index 1f87480..0402f72 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/serialization/jackson3/VersionedMapEventSerializationTest.kt @@ -1,9 +1,11 @@ package com.caplin.integration.datasourcex.util.serialization.jackson3 import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import tools.jackson.core.type.TypeReference +import tools.jackson.databind.DatabindException import tools.jackson.module.kotlin.jacksonMapperBuilder class VersionedMapEventSerializationTest : @@ -25,4 +27,18 @@ class VersionedMapEventSerializationTest : mapper.readValue(json, object : TypeReference>() {}) deserialized shouldBe event } + + test("a non-integral version is rejected") { + val json = """{"type":"upsert","key":"key","value":"value","version":"7"}""" + shouldThrow { + mapper.readValue(json, object : TypeReference>() {}) + } + } + + test("an explicit-null version is rejected") { + val json = """{"type":"upsert","key":"key","value":"value","version":null}""" + shouldThrow { + mapper.readValue(json, object : TypeReference>() {}) + } + } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index 4e10438..2ea4532 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -8,16 +8,18 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.engine.coroutines.backgroundScope import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onSubscription class FlowStoreTest : FunSpec({ test("get reads through to the store on a miss") { val store = InMemoryCacheLoaderWriter() - store.write("k", "seeded", 1L, AutoCommitTxContext(Unit)) + store.seed("k", "seeded", 1L) val consumer = flowStore( store, @@ -32,38 +34,66 @@ class FlowStoreTest : test("a delta older than a read-through load is dropped; a newer one is applied") { val store = InMemoryCacheLoaderWriter() - store.write("k", "v5", 5L, AutoCommitTxContext(Unit)) + store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + Caffeine.newBuilder().buildSuspending>(backgroundScope) val consumer = flowStore(store, inbound, cache, backgroundScope) - delay(1.milliseconds) - consumer.get("k") shouldBe "v5" + consumer.asFlow().test { + inbound.subscriptionCount.first { + it >= 1 + } // the store's collector has attached to inbound - inbound.emit(VersionedMapEvent.Upsert("k", "stale", 3L)) - delay(1.milliseconds) - consumer.get("k") shouldBe "v5" + consumer.get("k") shouldBe "v5" - inbound.emit(VersionedMapEvent.Upsert("k", "v9", 9L)) - delay(1.milliseconds) - consumer.get("k") shouldBe "v9" + inbound.emit(VersionedMapEvent.Upsert("k", "stale", 3L)) + awaitItem() // collector processed the delta (gated out) + consumer.get("k") shouldBe "v5" + + inbound.emit(VersionedMapEvent.Upsert("k", "v9", 9L)) + awaitItem() + consumer.get("k") shouldBe "v9" + } } test("an equal-version delta after a read-through is gated out") { val store = InMemoryCacheLoaderWriter() - store.write("k", "v2", 2L, AutoCommitTxContext(Unit)) + store.seed("k", "v2", 2L) + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val consumer = flowStore(store, inbound, cache, backgroundScope) + + consumer.asFlow().test { + inbound.subscriptionCount.first { it >= 1 } + + consumer.get("k") shouldBe "v2" + + inbound.emit(VersionedMapEvent.Upsert("k", "wrong", 2L)) + awaitItem() + consumer.get("k") shouldBe "v2" + } + } + + test("a removal tombstones a resident entry so a later read cannot resurrect it") { + val store = InMemoryCacheLoaderWriter() + store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + Caffeine.newBuilder().buildSuspending>(backgroundScope) val consumer = flowStore(store, inbound, cache, backgroundScope) - delay(1.milliseconds) - consumer.get("k") shouldBe "v2" + consumer.asFlow().test { + inbound.subscriptionCount.first { it >= 1 } + + consumer.get("k") shouldBe "v5" // resident Live(v5, 5) - inbound.emit(VersionedMapEvent.Upsert("k", "wrong", 2L)) - delay(1.milliseconds) - consumer.get("k") shouldBe "v2" + inbound.emit(VersionedMapEvent.Removed("k", 6L)) + awaitItem() + cache.getIfPresent("k") shouldBe Tombstone(6L) + consumer.get("k").shouldBeNull() // tombstone short-circuits the read-through + } } test("consumer converges to the owner's state through the delta stream") { @@ -74,16 +104,21 @@ class FlowStoreTest : inMemoryTransactionRunner, Caffeine.newBuilder().buildSuspending(backgroundScope), ) + val attached = MutableStateFlow(false) + val ownerDeltas = + (owner.asFlow() as SharedFlow>).onSubscription { + attached.value = true + } val consumer = flowStore( store, - owner.asFlow(), + ownerDeltas, Caffeine.newBuilder().buildSuspending(backgroundScope), backgroundScope, ) consumer.valueFlow("k").test { - delay(1.milliseconds) + attached.first { it } // the consumer's collector has attached to the owner's stream awaitItem().shouldBeNull() owner.put("k", "v1") diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt index 668e7b6..bc484a0 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt @@ -1,26 +1,39 @@ package com.caplin.integration.datasourcex.util.store import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow /** * In-memory reference implementation of the store SPI for tests. Writes apply immediately to a - * backing [ConcurrentHashMap]; the [TxContext] (type [Unit]) is ignored. + * backing [ConcurrentHashMap], versioned from a shared counter that stands in for a DB sequence; + * the [TxContext] (type [Unit]) is ignored. */ class InMemoryCacheLoaderWriter : CacheLoaderWriter { private val backing = ConcurrentHashMap>() + private val sequence = AtomicLong(0L) + + /** Seeds a specific version directly, for tests that exercise version gating. */ + fun seed(key: K, value: V, version: Long) { + backing[key] = Versioned(value, version) + sequence.updateAndGet { maxOf(it, version) } + } override suspend fun load(key: K): Versioned? = backing[key] override fun loadAllKeys(): Flow = backing.keys.toList().asFlow() - override suspend fun write(key: K, value: V, version: Long, tx: TxContext) { + override suspend fun write(key: K, value: V, tx: TxContext): Long { + val version = sequence.incrementAndGet() backing[key] = Versioned(value, version) + return version } - override suspend fun delete(key: K, version: Long, tx: TxContext) { + override suspend fun delete(key: K, tx: TxContext): Long { + val version = sequence.incrementAndGet() backing.remove(key) + return version } } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt index c7cf33c..71d05b3 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt @@ -11,15 +11,15 @@ class InMemoryCacheLoaderWriterTest : val store = InMemoryCacheLoaderWriter() val tx = AutoCommitTxContext(Unit) - store.write("a", "A", 1L, tx) - store.write("b", "B", 2L, tx) + store.write("a", "A", tx) + store.write("b", "B", tx) store.load("a") shouldBe Versioned("A", 1L) store.loadAll(listOf("a", "b", "missing")) shouldBe mapOf("a" to Versioned("A", 1L), "b" to Versioned("B", 2L)) store.loadAllKeys().toList().toSet() shouldBe setOf("a", "b") - store.delete("a", 3L, tx) + store.delete("a", tx) store.load("a").shouldBeNull() } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt index 563d341..df89b5a 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -17,7 +17,7 @@ class MutableFlowStoreTest : test("put publishes a versioned delta and updates the cache only on commit") { val backing = InMemoryCacheLoaderWriter() val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + Caffeine.newBuilder().buildSuspending>(backgroundScope) val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) store.asFlow().test { @@ -28,14 +28,14 @@ class MutableFlowStoreTest : tx.commit() awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v", 1L) - cache.getIfPresent("k") shouldBe Versioned("v", 1L) + cache.getIfPresent("k") shouldBe Live("v", 1L) } } test("rollback publishes nothing and the next write gets a strictly greater version") { val backing = InMemoryCacheLoaderWriter() val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + Caffeine.newBuilder().buildSuspending>(backgroundScope) val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) store.asFlow().test { @@ -51,10 +51,10 @@ class MutableFlowStoreTest : test("a writer failure propagates and leaves the cache and stream untouched") { val writer = mockk>() - coEvery { writer.write(any(), any(), any(), any()) } throws RuntimeException("boom") + coEvery { writer.write(any(), any(), any()) } throws RuntimeException("boom") val backing = InMemoryCacheLoaderWriter() val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + Caffeine.newBuilder().buildSuspending>(backgroundScope) val store = mutableFlowStore(backing, writer, inMemoryTransactionRunner, cache) store.asFlow().test { @@ -67,7 +67,7 @@ class MutableFlowStoreTest : test("get reflects the committed value only after commit") { val backing = InMemoryCacheLoaderWriter() val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + Caffeine.newBuilder().buildSuspending>(backgroundScope) val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) store.put("k", "v1") @@ -80,4 +80,21 @@ class MutableFlowStoreTest : tx.commit() store.get("k") shouldBe "v2" } + + test("remove publishes a Removed delta and tombstones the cache") { + val backing = InMemoryCacheLoaderWriter() + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + + store.asFlow().test { + store.put("k", "v1") + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v1", 1L) + + store.remove("k") + awaitItem() shouldBe VersionedMapEvent.Removed("k", 2L) + cache.getIfPresent("k") shouldBe Tombstone(2L) + store.get("k").shouldBeNull() + } + } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt new file mode 100644 index 0000000..4ec4749 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt @@ -0,0 +1,29 @@ +package com.caplin.integration.datasourcex.util.store + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class TxContextTest : + FunSpec({ + test("commit fires actions once and rejects reuse") { + var commits = 0 + val tx = AutoCommitTxContext(Unit) + tx.onCommitEnd { commits++ } + + tx.commit() + commits shouldBe 1 + shouldThrow { tx.commit() } + commits shouldBe 1 + } + + test("rollback is a no-op once committed and does not fire rollback actions") { + var rollbacks = 0 + val tx = AutoCommitTxContext(Unit) + tx.onRollback { rollbacks++ } + + tx.commit() + tx.rollback() + rollbacks shouldBe 0 + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt deleted file mode 100644 index 92cb72f..0000000 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/VersionSourceTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.caplin.integration.datasourcex.util.store - -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.comparables.shouldBeGreaterThan -import io.kotest.matchers.shouldBe -import java.util.concurrent.atomic.AtomicLong - -class VersionSourceTest : - FunSpec({ - test("CountingVersionSource is strictly increasing from start + 1") { - val source = CountingVersionSource(start = 100L) - - List(5) { source.next() } shouldBe listOf(101L, 102L, 103L, 104L, 105L) - } - - test("TimestampVersionSource bumps past a stalled clock") { - val source = TimestampVersionSource(nowMicros = { 1_000L }) - - List(4) { source.next() } shouldBe listOf(1_000L, 1_001L, 1_002L, 1_003L) - } - - test("TimestampVersionSource stays strictly increasing under a regressing clock") { - val clock = AtomicLong(10_000L) - val source = TimestampVersionSource(nowMicros = { clock.getAndAdd(-100L) }) - - val seq = List(50) { source.next() } - - seq.zipWithNext().forEach { (a, b) -> b shouldBeGreaterThan a } - } - }) From 11932106d9de25ce9f645c4716a1cef965f9baed Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Tue, 2 Jun 2026 19:03:59 +0100 Subject: [PATCH 03/13] Improve compatibility with transactions --- util/README.md | 7 +- util/api/datasourcex-util.api | 51 +++--- util/build.gradle.kts | 12 +- .../util/store/CacheLoaderWriter.kt | 14 +- .../datasourcex/util/store/CacheWriter.kt | 13 +- .../datasourcex/util/store/FlowStore.kt | 24 +-- .../util/store/MutableFlowStore.kt | 88 +++++----- .../util/store/TransactionRunner.kt | 6 - .../datasourcex/util/store/TxContext.kt | 21 ++- .../samples/kotlin/samples/StoreSamples.kt | 154 ++++++++++++++++++ .../datasourcex/util/store/FlowStoreTest.kt | 9 +- .../util/store/InMemoryCacheLoaderWriter.kt | 53 ++++-- .../store/InMemoryCacheLoaderWriterTest.kt | 2 +- .../util/store/MutableFlowStoreTest.kt | 58 +++++-- 14 files changed, 364 insertions(+), 148 deletions(-) delete mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt create mode 100644 util/src/samples/kotlin/samples/StoreSamples.kt diff --git a/util/README.md b/util/README.md index 5748175..d4a9272 100644 --- a/util/README.md +++ b/util/README.md @@ -3,4 +3,9 @@ Utility classes and functions commonly used in DataSource integrations, such as efficiently observable Maps, and stream transformations. -@see com.caplin.integration.datasourcex.util.flow.FlowMap \ No newline at end of file +@see com.caplin.integration.datasourcex.util.flow.FlowMap +@see com.caplin.integration.datasourcex.util.store.MutableFlowStore + +Participating in an application-owned jOOQ transaction: + +@sample samples.StoreSamples.jooqSample \ No newline at end of file diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index 00e024c..5e4d483 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -572,11 +572,11 @@ public final class com/caplin/integration/datasourcex/util/serialization/jackson public final class com/caplin/integration/datasourcex/util/store/AutoCommitTxContext : com/caplin/integration/datasourcex/util/store/TxContext { public fun (Ljava/lang/Object;)V - public final fun commit (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun commit ()V public fun getTransaction ()Ljava/lang/Object; - public fun onCommitEnd (Lkotlin/jvm/functions/Function1;)V - public fun onRollback (Lkotlin/jvm/functions/Function1;)V - public final fun rollback (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onCommitEnd (Lkotlin/jvm/functions/Function0;)V + public fun onRollback (Lkotlin/jvm/functions/Function0;)V + public final fun rollback ()V } public abstract interface class com/caplin/integration/datasourcex/util/store/CacheEntry { @@ -595,25 +595,27 @@ public final class com/caplin/integration/datasourcex/util/store/CacheLoader$Def } public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter : com/caplin/integration/datasourcex/util/store/CacheLoader, com/caplin/integration/datasourcex/util/store/CacheWriter { + public fun load (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Lcom/caplin/integration/datasourcex/util/store/Versioned; } public final class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter$DefaultImpls { - public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; + public static fun load (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Lcom/caplin/integration/datasourcex/util/store/Versioned; public static fun loadAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun loadAllKeys (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;)Lkotlinx/coroutines/flow/Flow; - public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; } public abstract interface class com/caplin/integration/datasourcex/util/store/CacheWriter { - public abstract fun delete (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun deleteAll (Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun writeAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun delete (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J + public fun deleteAll (Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; + public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J + public fun writeAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; } public final class com/caplin/integration/datasourcex/util/store/CacheWriter$DefaultImpls { - public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; + public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; } public abstract interface class com/caplin/integration/datasourcex/util/store/FlowStore { @@ -641,19 +643,14 @@ public final class com/caplin/integration/datasourcex/util/store/Live : com/capl } public abstract interface class com/caplin/integration/datasourcex/util/store/MutableFlowStore : com/caplin/integration/datasourcex/util/store/FlowStore { - public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun putAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun putAll (Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun remove (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun remove (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun get (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V + public abstract fun putAll (Ljava/util/Map;Ljava/lang/Object;)V + public abstract fun remove (Ljava/lang/Object;Ljava/lang/Object;)V } public final class com/caplin/integration/datasourcex/util/store/MutableFlowStoreKt { - public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;I)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; - public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;I)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; - public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; - public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/TransactionRunner;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlin/jvm/functions/Function1;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; } public final class com/caplin/integration/datasourcex/util/store/Tombstone : com/caplin/integration/datasourcex/util/store/CacheEntry { @@ -667,18 +664,14 @@ public final class com/caplin/integration/datasourcex/util/store/Tombstone : com public fun toString ()Ljava/lang/String; } -public abstract interface class com/caplin/integration/datasourcex/util/store/TransactionRunner { - public abstract fun run (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - public abstract interface class com/caplin/integration/datasourcex/util/store/TxContext { public abstract fun getTransaction ()Ljava/lang/Object; - public abstract fun onCommitEnd (Lkotlin/jvm/functions/Function1;)V - public fun onRollback (Lkotlin/jvm/functions/Function1;)V + public abstract fun onCommitEnd (Lkotlin/jvm/functions/Function0;)V + public fun onRollback (Lkotlin/jvm/functions/Function0;)V } public final class com/caplin/integration/datasourcex/util/store/TxContext$DefaultImpls { - public static fun onRollback (Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/jvm/functions/Function1;)V + public static fun onRollback (Lcom/caplin/integration/datasourcex/util/store/TxContext;Lkotlin/jvm/functions/Function0;)V } public final class com/caplin/integration/datasourcex/util/store/Versioned { diff --git a/util/build.gradle.kts b/util/build.gradle.kts index b92aea7..7cf0e3a 100644 --- a/util/build.gradle.kts +++ b/util/build.gradle.kts @@ -33,6 +33,11 @@ dependencies { compileOnly(libs.fory.core) compileOnly(libs.fory.kotlin) + // Samples (compiled for Dokka only): show MutableFlowStore participating in a blocking jOOQ + // transaction. Off the published runtime classpath; jooq is version-managed by the Spring Boot + // BOM. + samplesImplementation("org.jooq:jooq") + testRuntimeOnly("org.slf4j:slf4j-simple") testImplementation("org.springframework:spring-core") // For testing the RegexPathMatcher @@ -53,4 +58,9 @@ dependencies { jmh { duplicateClassesStrategy.set(DuplicatesStrategy.EXCLUDE) } -dokka { dokkaSourceSets.configureEach { includes.from("README.md") } } +dokka { + dokkaSourceSets.configureEach { + includes.from("README.md") + samples.from(layout.projectDirectory.dir("src/samples/kotlin")) + } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt index 6f3d43b..c1c287d 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt @@ -1,4 +1,16 @@ package com.caplin.integration.datasourcex.util.store /** Combines [CacheLoader] and [CacheWriter] for a backend that implements both. */ -interface CacheLoaderWriter : CacheLoader, CacheWriter +interface CacheLoaderWriter : CacheLoader, CacheWriter { + /** + * Reads [key]'s current persisted value within [tx] for read-modify-write, e.g. a locking `SELECT + * … FOR UPDATE` on the transaction's connection. Like the writes it is non-suspending, so it runs + * on the caller's blocking transaction; override it to support [MutableFlowStore.get] within a + * transaction (the default throws). The read should see the transaction's own uncommitted writes + * and serialise concurrent writers. + */ + fun load(key: K, tx: TxContext): Versioned? = + throw UnsupportedOperationException( + "Override load(key, tx) to support get(key, tx) / read-modify-write", + ) +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt index 7abdfca..fa342f6 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt @@ -5,21 +5,26 @@ package com.caplin.integration.datasourcex.util.store * commit the transaction themselves. Each write assigns and returns the new version: the version is * the store's commit order (a sequence, identity, or version column), never supplied by the caller, * so it survives restarts and orders writes the way the store committed them. + * + * Writes are **not** suspending: they run synchronously on the transaction the caller already + * opened (a blocking jOOQ / JDBC transaction callback), so a [MutableFlowStore] can be driven from + * inside `dsl.transaction { … }` without a `runBlocking` bridge. Dispatch the whole transaction to + * a blocking-friendly dispatcher (`withContext(Dispatchers.IO)`) at its boundary. */ interface CacheWriter { /** Writes [value] for [key] and returns the version the store assigned it. */ - suspend fun write(key: K, value: V, tx: TxContext): Long + fun write(key: K, value: V, tx: TxContext): Long /** Writes many entries, returning the version assigned to each. */ - suspend fun writeAll(values: Map, tx: TxContext): Map = buildMap { + fun writeAll(values: Map, tx: TxContext): Map = buildMap { for ((key, value) in values) put(key, write(key, value, tx)) } /** Deletes [key] and returns the version the store assigned the removal. */ - suspend fun delete(key: K, tx: TxContext): Long + fun delete(key: K, tx: TxContext): Long /** Deletes many keys, returning the version assigned to each removal. */ - suspend fun deleteAll(keys: Collection, tx: TxContext): Map = buildMap { + fun deleteAll(keys: Collection, tx: TxContext): Map = buildMap { for (key in keys) put(key, delete(key, tx)) } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index ed64919..3f6077e 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -33,9 +33,10 @@ interface FlowStore { } /** - * Owning, read/write view of a store-backed map. Writes are written through [CacheWriter], which - * assigns each write its version, and the cache update and delta are published only when the - * enclosing transaction commits. + * Read/write view of a store-backed map that **participates in** transactions the caller owns. + * Every mutation takes the backend's transaction handle [T] (a jOOQ `Configuration`, a JDBC + * `Connection`, …): the write is enlisted on it through [CacheWriter], which assigns the version, + * and the cache update and delta are published only when that transaction commits. * * The owner must **serialise writes to a given key** (single-writer-per-key): the version is the * store's commit order, so concurrent unserialised writes to the same key would let the persisted @@ -43,17 +44,18 @@ interface FlowStore { * [FlowStore]. */ interface MutableFlowStore : FlowStore { - suspend fun put(key: K, value: V) + /** + * Reads [key]'s current value within [tx], always through the store and bypassing the cache, so + * it sees this transaction's own uncommitted writes and can take a locking read. Use for + * read-modify-write; use the cache-first [get] outside a transaction. + */ + fun get(key: K, tx: T): V? - suspend fun put(key: K, value: V, tx: TxContext) + fun put(key: K, value: V, tx: T) - suspend fun putAll(from: Map) + fun putAll(from: Map, tx: T) - suspend fun putAll(from: Map, tx: TxContext) - - suspend fun remove(key: K) - - suspend fun remove(key: K, tx: TxContext) + fun remove(key: K, tx: T) } /** diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index 23d9228..be71e50 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -4,71 +4,65 @@ import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent /** - * Creates a [MutableFlowStore] backed by [loader] / [writer] and the bounded hot set [cache]. The - * [writer] assigns each write's version. [V] must be an aggregate root — see [FlowStore]. - */ -fun mutableFlowStore( - loader: CacheLoader, - writer: CacheWriter, - transactionRunner: TransactionRunner, - cache: SuspendingCache>, - bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, -): MutableFlowStore = - MutableFlowStoreImpl(loader, writer, transactionRunner, cache, bufferCapacity) - -/** - * Creates a [MutableFlowStore] backed by a combined [CacheLoaderWriter] and the bounded hot set - * [cache]. [V] must be an aggregate root — see [FlowStore]. + * Creates a [MutableFlowStore] backed by [store] and the bounded hot set [cache]. + * + * The store **participates in** transactions the caller owns rather than opening them: each + * mutation takes the backend's transaction handle [T] (a jOOQ `Configuration`, a JDBC `Connection`, + * …), which [txContext] adapts into a [TxContext] so the write can enlist and register its publish. + * Mutations are non-suspending and run synchronously on the caller's (blocking) transaction; on + * commit the store refreshes its cache and `tryEmit`s the delta onto its stream. The stream's + * buffer is unbounded, so the commit callback never suspends or blocks on stream backpressure: a + * slow consumer grows the buffer rather than parking the committing thread. [store] assigns each + * write's version. [V] must be an aggregate root — see [FlowStore]. */ fun mutableFlowStore( store: CacheLoaderWriter, - transactionRunner: TransactionRunner, cache: SuspendingCache>, - bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, -): MutableFlowStore = - mutableFlowStore(store, store, transactionRunner, cache, bufferCapacity) + txContext: (T) -> TxContext, +): MutableFlowStore = MutableFlowStoreImpl(store, store, cache, txContext) internal class MutableFlowStoreImpl( loader: CacheLoader, - private val writer: CacheWriter, - private val transactionRunner: TransactionRunner, + private val writer: CacheLoaderWriter, cache: SuspendingCache>, - bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, -) : AbstractFlowStore(loader, cache, bufferCapacity), MutableFlowStore { + private val txContext: (T) -> TxContext, + // Unbounded buffer so the commit callback's tryEmit always succeeds without suspending: a slow + // consumer grows the buffer, never blocks the committing thread. +) : AbstractFlowStore(loader, cache, Int.MAX_VALUE), MutableFlowStore { - override suspend fun put(key: K, value: V) = transactionRunner.run { put(key, value, it) } + override fun get(key: K, tx: T): V? = writer.load(key, txContext(tx))?.value - override suspend fun put(key: K, value: V, tx: TxContext) { - val version = writer.write(key, value, tx) - tx.onCommitEnd { - // Publish the delta first, then refresh the local cache: a cancellation between the two would - // otherwise leave the owner cache ahead of a delta no consumer ever sees. The cache write is - // version-gated so an interleaved post-commit reflection cannot regress it. - signal.emit(VersionedMapEvent.Upsert(key, value, version)) - cachePutIfNewer(key, Live(value, version)) - } + override fun put(key: K, value: V, tx: T) { + val ctx = txContext(tx) + val version = writer.write(key, value, ctx) + ctx.onCommitEnd { publish(VersionedMapEvent.Upsert(key, value, version), Live(value, version)) } } - override suspend fun putAll(from: Map) = transactionRunner.run { putAll(from, it) } - - override suspend fun putAll(from: Map, tx: TxContext) { - val versions = writer.writeAll(from, tx) - tx.onCommitEnd { + override fun putAll(from: Map, tx: T) { + val ctx = txContext(tx) + val versions = writer.writeAll(from, ctx) + ctx.onCommitEnd { versions.forEach { (key, version) -> val value = from.getValue(key) - signal.emit(VersionedMapEvent.Upsert(key, value, version)) - cachePutIfNewer(key, Live(value, version)) + publish(VersionedMapEvent.Upsert(key, value, version), Live(value, version)) } } } - override suspend fun remove(key: K) = transactionRunner.run { remove(key, it) } + override fun remove(key: K, tx: T) { + val ctx = txContext(tx) + val version = writer.delete(key, ctx) + ctx.onCommitEnd { publish(VersionedMapEvent.Removed(key, version), Tombstone(version)) } + } - override suspend fun remove(key: K, tx: TxContext) { - val version = writer.delete(key, tx) - tx.onCommitEnd { - signal.emit(VersionedMapEvent.Removed(key, version)) - cachePutIfNewer(key, Tombstone(version)) - } + /** + * Emits [event] before refreshing the cache with [entry]: the (unbounded) emit always accepts the + * delta, so the owner cache can never sit ahead of an unpublished delta. The cache write is + * version-gated so an interleaved post-commit reflection cannot regress it. Both steps are + * non-suspending, so this runs in full inside the synchronous commit callback. + */ + private fun publish(event: VersionedMapEvent, entry: CacheEntry) { + signal.tryEmit(event) + cachePutIfNewer(event.key, entry) } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt deleted file mode 100644 index dabbabc..0000000 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TransactionRunner.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.caplin.integration.datasourcex.util.store - -/** Opens a unit of work, runs [block], then commits on success or rolls back on failure. */ -fun interface TransactionRunner { - suspend fun run(block: suspend (TxContext) -> Unit) -} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt index 9b8a2a7..a7bd1f3 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt @@ -7,13 +7,18 @@ import java.util.concurrent.atomic.AtomicBoolean * A driver-agnostic unit of work. [transaction] is the backend handle (jOOQ, JDBC, R2DBC, ...). The * owner of the transaction begins and commits it; callers only register post-commit side-effects * via [onCommitEnd] (and optional [onRollback] cleanup). + * + * Registered actions are **non-suspending** so they can run inside a synchronous commit/rollback + * callback (e.g. a jOOQ `TransactionListener`) without blocking. The store keeps them cheap — a + * cache update and an enqueue onto its delta channel — and defers any suspending work to its own + * coroutine. */ interface TxContext { val transaction: T - fun onCommitEnd(action: suspend () -> Unit) + fun onCommitEnd(action: () -> Unit) - fun onRollback(action: suspend () -> Unit) {} + fun onRollback(action: () -> Unit) {} } /** @@ -22,20 +27,20 @@ interface TxContext { * single unit of work directly. */ class AutoCommitTxContext(override val transaction: T) : TxContext { - private val commitActions = CopyOnWriteArrayList Unit>() - private val rollbackActions = CopyOnWriteArrayList Unit>() + private val commitActions = CopyOnWriteArrayList<() -> Unit>() + private val rollbackActions = CopyOnWriteArrayList<() -> Unit>() private val completed = AtomicBoolean(false) - override fun onCommitEnd(action: suspend () -> Unit) { + override fun onCommitEnd(action: () -> Unit) { commitActions += action } - override fun onRollback(action: suspend () -> Unit) { + override fun onRollback(action: () -> Unit) { rollbackActions += action } /** Fires the registered commit actions once; reusing a completed context throws. */ - suspend fun commit() { + fun commit() { check(completed.compareAndSet(false, true)) { "Transaction already completed" } for (action in commitActions) action() } @@ -43,7 +48,7 @@ class AutoCommitTxContext(override val transaction: T) : TxContext { /** * Fires the registered rollback actions; a no-op once the context has committed or rolled back. */ - suspend fun rollback() { + fun rollback() { if (!completed.compareAndSet(false, true)) return for (action in rollbackActions) action() } diff --git a/util/src/samples/kotlin/samples/StoreSamples.kt b/util/src/samples/kotlin/samples/StoreSamples.kt new file mode 100644 index 0000000..0858e36 --- /dev/null +++ b/util/src/samples/kotlin/samples/StoreSamples.kt @@ -0,0 +1,154 @@ +package samples + +import com.caplin.integration.datasourcex.util.cache.buildSuspending +import com.caplin.integration.datasourcex.util.store.CacheEntry +import com.caplin.integration.datasourcex.util.store.CacheLoaderWriter +import com.caplin.integration.datasourcex.util.store.TxContext +import com.caplin.integration.datasourcex.util.store.Versioned +import com.caplin.integration.datasourcex.util.store.mutableFlowStore +import com.github.benmanes.caffeine.cache.Caffeine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jooq.Configuration +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.TransactionContext +import org.jooq.TransactionListener +import org.jooq.impl.DefaultTransactionListenerProvider + +class StoreSamples { + + /** The aggregate root stored under each key — replaced as a whole on every write. */ + data class Account(val id: String, val balance: Long) + + /** + * A [CacheLoaderWriter] over a jOOQ `account` table whose `version` is drawn from a database + * sequence, so the version is the store's durable commit order rather than an in-process counter. + * + * The transactional operations ([write], [delete] and the read-modify-write [load]) are + * non-suspending: they run on the caller's blocking transaction (`tx.transaction`), already + * dispatched to [Dispatchers.IO] by [flowStoreTransaction]. Only the cache-miss read-through + * [load] is suspending, so it dispatches its own blocking jOOQ call to [Dispatchers.IO]. + */ + class JooqAccountStore(private val dsl: DSLContext) : + CacheLoaderWriter { + + override suspend fun load(key: String): Versioned? = + withContext(Dispatchers.IO) { + dsl.fetchOne("select balance, version from account where id = ?", key)?.toVersioned(key) + } + + /** A locking read on the transaction's connection so a read-modify-write serialises writers. */ + override fun load(key: String, tx: TxContext): Versioned? = + tx.transaction + .dsl() + .fetchOne("select balance, version from account where id = ? for update", key) + ?.toVersioned(key) + + override fun write(key: String, value: Account, tx: TxContext): Long = + tx.transaction + .dsl() + .fetchOne( + """ + insert into account (id, balance, version) + values (?, ?, nextval('account_version')) + on conflict (id) do update + set balance = excluded.balance, version = nextval('account_version') + returning version + """, + key, + value.balance, + )!! + .get("version", Long::class.java) + + override fun delete(key: String, tx: TxContext): Long = + tx.transaction + .dsl() + .fetchOne( + "delete from account where id = ? returning nextval('account_version') as version", + key, + )!! + .get("version", Long::class.java) + + private fun Record.toVersioned(key: String): Versioned = + Versioned(Account(key, get("balance", Long::class.java)), get("version", Long::class.java)) + } + + /** + * Wires a [mutableFlowStore] that *participates in* the blocking jOOQ transactions the + * application owns — no R2DBC. Installing [FlowStorePublishingListener] once means every + * `dsl.transaction { … }` publishes the store's buffered deltas automatically on commit (or + * discards them on rollback), so callers write plain jOOQ transactions with no per-transaction + * wrapper. The store's mutations are non-suspending and run directly on the transaction. + * + * Moving funds between two accounts is one transaction: the locking read-modify-write + * (`store.get(key, config)`) and both writes run on it, and both deltas publish together on + * commit or neither on rollback. + */ + suspend fun jooqSample(rootDsl: DSLContext, scope: CoroutineScope) { + val cache = + Caffeine.newBuilder() + .maximumSize(10_000) + .buildSuspending>(scope) + val store = + mutableFlowStore(JooqAccountStore(rootDsl), cache, txContext = Configuration::asTxContext) + + // Install the publishing listener once; every transaction below then drains the buffered + // commit/rollback actions itself — no flowStoreTransaction wrapper to remember. + val dsl = + rootDsl + .configuration() + .derive(DefaultTransactionListenerProvider(FlowStorePublishingListener)) + .dsl() + + withContext(Dispatchers.IO) { + dsl.transaction { config -> + val alice = store.get("alice", config) ?: Account("alice", 0) + val bob = store.get("bob", config) ?: Account("bob", 0) + store.put("alice", alice.copy(balance = alice.balance - 10), config) + store.put("bob", bob.copy(balance = bob.balance + 10), config) + } + } + } +} + +private const val COMMIT_ACTIONS = "datasourcex.flowstore.commit-actions" +private const val ROLLBACK_ACTIONS = "datasourcex.flowstore.rollback-actions" + +/** + * Drains the store's buffered post-commit / rollback actions from within jOOQ's transaction + * lifecycle, so a plain `dsl.transaction { … }` publishes the deltas automatically. The actions are + * non-suspending (the store enqueues each delta and emits it from its own coroutine), so they run + * directly in these synchronous callbacks — no `runBlocking` and no blocked thread. + */ +private object FlowStorePublishingListener : TransactionListener { + override fun commitEnd(ctx: TransactionContext) = ctx.configuration().runActions(COMMIT_ACTIONS) + + override fun rollbackEnd(ctx: TransactionContext) = + ctx.configuration().runActions(ROLLBACK_ACTIONS) +} + +/** + * Adapts a jOOQ transaction [Configuration] to a [TxContext], buffering the store's post-commit + * side effects in the transaction-scoped [Configuration.data] so [flowStoreTransaction] can run + * them after the commit or discard them on rollback. + */ +private fun Configuration.asTxContext(): TxContext = + object : TxContext { + override val transaction: Configuration = this@asTxContext + + override fun onCommitEnd(action: () -> Unit) { + actions(COMMIT_ACTIONS).add(action) + } + + override fun onRollback(action: () -> Unit) { + actions(ROLLBACK_ACTIONS).add(action) + } + } + +private fun Configuration.runActions(key: String) = actions(key).forEach { it() } + +@Suppress("UNCHECKED_CAST") +private fun Configuration.actions(key: String): MutableList<() -> Unit> = + (data(key) as? MutableList<() -> Unit>) ?: mutableListOf<() -> Unit>().also { data(key, it) } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index 2ea4532..3485b06 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -101,9 +101,10 @@ class FlowStoreTest : val owner = mutableFlowStore( store, - inMemoryTransactionRunner, Caffeine.newBuilder().buildSuspending(backgroundScope), + txContext = inMemoryTxContext, ) + fun commit(action: (InMemoryTx) -> Unit) = InMemoryTx().also { action(it) }.commit() val attached = MutableStateFlow(false) val ownerDeltas = (owner.asFlow() as SharedFlow>).onSubscription { @@ -121,13 +122,13 @@ class FlowStoreTest : attached.first { it } // the consumer's collector has attached to the owner's stream awaitItem().shouldBeNull() - owner.put("k", "v1") + commit { owner.put("k", "v1", it) } awaitItem() shouldBe "v1" - owner.put("k", "v2") + commit { owner.put("k", "v2", it) } awaitItem() shouldBe "v2" - owner.remove("k") + commit { owner.remove("k", it) } awaitItem().shouldBeNull() } } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt index bc484a0..84429bf 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt @@ -5,12 +5,41 @@ import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow +/** + * A trivial in-memory unit of work standing in for a database transaction: it buffers the store's + * post-commit / rollback side effects until the test drives [commit] or [rollback]. Mirrors how a + * jOOQ `Configuration` carries transaction-scoped state. + */ +class InMemoryTx { + internal val commitActions = mutableListOf<() -> Unit>() + internal val rollbackActions = mutableListOf<() -> Unit>() + + fun commit() = commitActions.forEach { it() } + + fun rollback() = rollbackActions.forEach { it() } +} + +/** Adapts an [InMemoryTx] handle into the [TxContext] the store enlists on. */ +val inMemoryTxContext: (InMemoryTx) -> TxContext = { handle -> + object : TxContext { + override val transaction = handle + + override fun onCommitEnd(action: () -> Unit) { + handle.commitActions += action + } + + override fun onRollback(action: () -> Unit) { + handle.rollbackActions += action + } + } +} + /** * In-memory reference implementation of the store SPI for tests. Writes apply immediately to a * backing [ConcurrentHashMap], versioned from a shared counter that stands in for a DB sequence; - * the [TxContext] (type [Unit]) is ignored. + * the [TxContext] is ignored. */ -class InMemoryCacheLoaderWriter : CacheLoaderWriter { +class InMemoryCacheLoaderWriter : CacheLoaderWriter { private val backing = ConcurrentHashMap>() private val sequence = AtomicLong(0L) @@ -22,31 +51,19 @@ class InMemoryCacheLoaderWriter : CacheLoaderWriter? = backing[key] + override fun load(key: K, tx: TxContext): Versioned? = backing[key] + override fun loadAllKeys(): Flow = backing.keys.toList().asFlow() - override suspend fun write(key: K, value: V, tx: TxContext): Long { + override fun write(key: K, value: V, tx: TxContext): Long { val version = sequence.incrementAndGet() backing[key] = Versioned(value, version) return version } - override suspend fun delete(key: K, tx: TxContext): Long { + override fun delete(key: K, tx: TxContext): Long { val version = sequence.incrementAndGet() backing.remove(key) return version } } - -/** - * A [TransactionRunner] over a [Unit] transaction that commits on success and rolls back on error. - */ -val inMemoryTransactionRunner = TransactionRunner { block -> - val tx = AutoCommitTxContext(Unit) - try { - block(tx) - tx.commit() - } catch (e: Throwable) { - tx.rollback() - throw e - } -} diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt index 71d05b3..4ff6f3f 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt @@ -9,7 +9,7 @@ class InMemoryCacheLoaderWriterTest : FunSpec({ test("writes are loadable and deletes remove the entry") { val store = InMemoryCacheLoaderWriter() - val tx = AutoCommitTxContext(Unit) + val tx = inMemoryTxContext(InMemoryTx()) store.write("a", "A", tx) store.write("b", "B", tx) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt index df89b5a..b057d9c 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -9,8 +9,9 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.engine.coroutines.backgroundScope import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe -import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk +import java.util.concurrent.CompletableFuture class MutableFlowStoreTest : FunSpec({ @@ -18,10 +19,10 @@ class MutableFlowStoreTest : val backing = InMemoryCacheLoaderWriter() val cache = Caffeine.newBuilder().buildSuspending>(backgroundScope) - val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { - val tx = AutoCommitTxContext(Unit) + val tx = InMemoryTx() store.put("k", "v", tx) expectNoEvents() cache.getIfPresent("k").shouldBeNull() @@ -36,29 +37,30 @@ class MutableFlowStoreTest : val backing = InMemoryCacheLoaderWriter() val cache = Caffeine.newBuilder().buildSuspending>(backgroundScope) - val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { - val tx = AutoCommitTxContext(Unit) + val tx = InMemoryTx() store.put("k", "v1", tx) tx.rollback() expectNoEvents() - store.put("k", "v2") + val tx2 = InMemoryTx() + store.put("k", "v2", tx2) + tx2.commit() awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v2", 2L) } } test("a writer failure propagates and leaves the cache and stream untouched") { - val writer = mockk>() - coEvery { writer.write(any(), any(), any()) } throws RuntimeException("boom") - val backing = InMemoryCacheLoaderWriter() + val backing = mockk>() + every { backing.write(any(), any(), any()) } throws RuntimeException("boom") val cache = Caffeine.newBuilder().buildSuspending>(backgroundScope) - val store = mutableFlowStore(backing, writer, inMemoryTransactionRunner, cache) + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { - shouldThrow { store.put("k", "v") } + shouldThrow { store.put("k", "v", InMemoryTx()) } expectNoEvents() cache.getIfPresent("k").shouldBeNull() } @@ -68,12 +70,15 @@ class MutableFlowStoreTest : val backing = InMemoryCacheLoaderWriter() val cache = Caffeine.newBuilder().buildSuspending>(backgroundScope) - val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) - store.put("k", "v1") + InMemoryTx().also { + store.put("k", "v1", it) + it.commit() + } store.get("k") shouldBe "v1" - val tx = AutoCommitTxContext(Unit) + val tx = InMemoryTx() store.put("k", "v2", tx) store.get("k") shouldBe "v1" @@ -81,17 +86,36 @@ class MutableFlowStoreTest : store.get("k") shouldBe "v2" } + test("get(key, tx) reads through the store within the transaction, bypassing the cache") { + val backing = InMemoryCacheLoaderWriter() + backing.seed("k", "fresh-in-db", 9L) + val cache = + Caffeine.newBuilder().buildSuspending>(backgroundScope) + // A stale, lower-versioned entry sits in the cache. + cache.asyncCache().put("k", CompletableFuture.completedFuture(Live("stale-in-cache", 1L))) + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + + store.get("k") shouldBe "stale-in-cache" // cache-first + store.get("k", InMemoryTx()) shouldBe "fresh-in-db" // bypasses the cache, reads the store + } + test("remove publishes a Removed delta and tombstones the cache") { val backing = InMemoryCacheLoaderWriter() val cache = Caffeine.newBuilder().buildSuspending>(backgroundScope) - val store = mutableFlowStore(backing, inMemoryTransactionRunner, cache) + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { - store.put("k", "v1") + InMemoryTx().also { + store.put("k", "v1", it) + it.commit() + } awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v1", 1L) - store.remove("k") + InMemoryTx().also { + store.remove("k", it) + it.commit() + } awaitItem() shouldBe VersionedMapEvent.Removed("k", 2L) cache.getIfPresent("k") shouldBe Tombstone(2L) store.get("k").shouldBeNull() From 465c6eb7556453e8ba64ad1901eda5a540c4560c Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Tue, 2 Jun 2026 19:20:57 +0100 Subject: [PATCH 04/13] Simplify store internals and tidy doc comments - Centralize the VersionedMapEvent -> CacheEntry mapping in one toEntry() helper instead of restating it across the owner and consumer paths. - Add AbstractFlowStore.cacheReflectIfNewer and collapse the consumer's hand-rolled version-gated computeIfPresent onto it. - Drop the redundant loader param from MutableFlowStoreImpl (always the writer). - Rewrite KDoc/sample docs to describe behaviour without rejected-alternative framing, and drop stale references to the removed delta channel. --- .../util/store/AbstractFlowStore.kt | 20 +++++++++ .../datasourcex/util/store/CacheWriter.kt | 7 ++-- .../datasourcex/util/store/FlowStore.kt | 27 +++--------- .../util/store/MutableFlowStore.kt | 41 +++++++++---------- .../datasourcex/util/store/TxContext.kt | 5 +-- .../samples/kotlin/samples/StoreSamples.kt | 37 ++++++++--------- 6 files changed, 65 insertions(+), 72 deletions(-) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt index 4b28178..8676ec9 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -62,6 +62,20 @@ internal abstract class AbstractFlowStore( return resident?.getNow(candidate) ?: candidate } + /** + * Reflects [event] onto a *resident* entry only, gating on version — the consumer counterpart to + * [cachePutIfNewer], which also inserts. A removal leaves a tombstone so a stale older + * read-through is rejected by version; non-resident keys are left for the next read-through. + */ + protected fun cacheReflectIfNewer(event: VersionedMapEvent) { + cache.asyncCache().asMap().computeIfPresent(event.key) { _, oldFuture -> + val old = oldFuture.getNow(null) + if (old != null && event.version > old.version) + CompletableFuture.completedFuture(event.toEntry()) + else oldFuture + } + } + override fun valueFlow(key: K): Flow = flow { var highest = Long.MIN_VALUE @@ -92,3 +106,9 @@ internal fun VersionedMapEvent<*, V>.valueOrNull(): V? = is VersionedMapEvent.Upsert -> value is VersionedMapEvent.Removed -> null } + +internal fun VersionedMapEvent<*, V>.toEntry(): CacheEntry = + when (this) { + is VersionedMapEvent.Upsert -> Live(value, version) + is VersionedMapEvent.Removed -> Tombstone(version) + } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt index fa342f6..dc13daa 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt @@ -6,10 +6,9 @@ package com.caplin.integration.datasourcex.util.store * the store's commit order (a sequence, identity, or version column), never supplied by the caller, * so it survives restarts and orders writes the way the store committed them. * - * Writes are **not** suspending: they run synchronously on the transaction the caller already - * opened (a blocking jOOQ / JDBC transaction callback), so a [MutableFlowStore] can be driven from - * inside `dsl.transaction { … }` without a `runBlocking` bridge. Dispatch the whole transaction to - * a blocking-friendly dispatcher (`withContext(Dispatchers.IO)`) at its boundary. + * Writes are **not** suspending: they run on the caller's transaction, which may be a blocking jOOQ + * / JDBC transaction callback (`dsl.transaction { … }`). Dispatch the whole transaction to a + * blocking-friendly dispatcher (`withContext(Dispatchers.IO)`) at its boundary. */ interface CacheWriter { /** Writes [value] for [key] and returns the version the store assigned it. */ diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index 3f6077e..bdf3cfb 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -2,7 +2,6 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent -import java.util.concurrent.CompletableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch @@ -33,10 +32,10 @@ interface FlowStore { } /** - * Read/write view of a store-backed map that **participates in** transactions the caller owns. - * Every mutation takes the backend's transaction handle [T] (a jOOQ `Configuration`, a JDBC - * `Connection`, …): the write is enlisted on it through [CacheWriter], which assigns the version, - * and the cache update and delta are published only when that transaction commits. + * Read/write view of a store-backed map. Every mutation takes the caller's transaction handle [T] + * (a jOOQ `Configuration`, a JDBC `Connection`, …): the write is enlisted on it through + * [CacheWriter], which assigns the version, and the cache update and delta are published only when + * that transaction commits. * * The owner must **serialise writes to a given key** (single-writer-per-key): the version is the * store's commit order, so concurrent unserialised writes to the same key would let the persisted @@ -82,23 +81,7 @@ internal class FlowStoreImpl( init { scope.launch { inbound.collect { event -> - // Keep a resident entry coherent, gating on version with the cache's per-key atomic compute - // so neither a concurrent read-through nor an out-of-order delta can clobber a newer value. - // A removal leaves a tombstone so a stale older read-through is rejected by version; - // non-resident keys are left for the next read-through. - cache.asyncCache().asMap().computeIfPresent(event.key) { _, oldFuture -> - val old = oldFuture.getNow(null) - if (old == null || event.version <= old.version) { - oldFuture - } else { - val replacement: CacheEntry = - when (event) { - is VersionedMapEvent.Upsert -> Live(event.value, event.version) - is VersionedMapEvent.Removed -> Tombstone(event.version) - } - CompletableFuture.completedFuture(replacement) - } - } + cacheReflectIfNewer(event) signal.emit(event) } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index be71e50..2d3cec7 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -6,45 +6,42 @@ import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent /** * Creates a [MutableFlowStore] backed by [store] and the bounded hot set [cache]. * - * The store **participates in** transactions the caller owns rather than opening them: each - * mutation takes the backend's transaction handle [T] (a jOOQ `Configuration`, a JDBC `Connection`, - * …), which [txContext] adapts into a [TxContext] so the write can enlist and register its publish. - * Mutations are non-suspending and run synchronously on the caller's (blocking) transaction; on - * commit the store refreshes its cache and `tryEmit`s the delta onto its stream. The stream's - * buffer is unbounded, so the commit callback never suspends or blocks on stream backpressure: a - * slow consumer grows the buffer rather than parking the committing thread. [store] assigns each - * write's version. [V] must be an aggregate root — see [FlowStore]. + * Each mutation takes the caller's transaction handle [T] (a jOOQ `Configuration`, a JDBC + * `Connection`, …), which [txContext] adapts into a [TxContext] so the write can enlist on it and + * register its publish. Mutations are non-suspending and run on that transaction; on commit the + * store refreshes its cache and `tryEmit`s the delta onto its stream. The stream's buffer is + * unbounded, so the commit callback never suspends on stream backpressure — a slow consumer grows + * the buffer instead. [store] assigns each write's version. [V] must be an aggregate root — see + * [FlowStore]. */ fun mutableFlowStore( store: CacheLoaderWriter, cache: SuspendingCache>, txContext: (T) -> TxContext, -): MutableFlowStore = MutableFlowStoreImpl(store, store, cache, txContext) +): MutableFlowStore = MutableFlowStoreImpl(store, cache, txContext) internal class MutableFlowStoreImpl( - loader: CacheLoader, private val writer: CacheLoaderWriter, cache: SuspendingCache>, private val txContext: (T) -> TxContext, // Unbounded buffer so the commit callback's tryEmit always succeeds without suspending: a slow // consumer grows the buffer, never blocks the committing thread. -) : AbstractFlowStore(loader, cache, Int.MAX_VALUE), MutableFlowStore { +) : AbstractFlowStore(writer, cache, Int.MAX_VALUE), MutableFlowStore { override fun get(key: K, tx: T): V? = writer.load(key, txContext(tx))?.value override fun put(key: K, value: V, tx: T) { val ctx = txContext(tx) val version = writer.write(key, value, ctx) - ctx.onCommitEnd { publish(VersionedMapEvent.Upsert(key, value, version), Live(value, version)) } + ctx.onCommitEnd { publish(VersionedMapEvent.Upsert(key, value, version)) } } override fun putAll(from: Map, tx: T) { val ctx = txContext(tx) val versions = writer.writeAll(from, ctx) ctx.onCommitEnd { - versions.forEach { (key, version) -> - val value = from.getValue(key) - publish(VersionedMapEvent.Upsert(key, value, version), Live(value, version)) + from.forEach { (key, value) -> + publish(VersionedMapEvent.Upsert(key, value, versions.getValue(key))) } } } @@ -52,17 +49,17 @@ internal class MutableFlowStoreImpl( override fun remove(key: K, tx: T) { val ctx = txContext(tx) val version = writer.delete(key, ctx) - ctx.onCommitEnd { publish(VersionedMapEvent.Removed(key, version), Tombstone(version)) } + ctx.onCommitEnd { publish(VersionedMapEvent.Removed(key, version)) } } /** - * Emits [event] before refreshing the cache with [entry]: the (unbounded) emit always accepts the - * delta, so the owner cache can never sit ahead of an unpublished delta. The cache write is - * version-gated so an interleaved post-commit reflection cannot regress it. Both steps are - * non-suspending, so this runs in full inside the synchronous commit callback. + * Emits [event] before refreshing the cache: the (unbounded) emit always accepts the delta, so + * the owner cache can never sit ahead of an unpublished delta. The cache write is version-gated + * so an interleaved post-commit reflection cannot regress it. Both steps are non-suspending, so + * this runs in full inside the synchronous commit callback. */ - private fun publish(event: VersionedMapEvent, entry: CacheEntry) { + private fun publish(event: VersionedMapEvent) { signal.tryEmit(event) - cachePutIfNewer(event.key, entry) + cachePutIfNewer(event.key, event.toEntry()) } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt index a7bd1f3..8b0ecbb 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt @@ -9,9 +9,8 @@ import java.util.concurrent.atomic.AtomicBoolean * via [onCommitEnd] (and optional [onRollback] cleanup). * * Registered actions are **non-suspending** so they can run inside a synchronous commit/rollback - * callback (e.g. a jOOQ `TransactionListener`) without blocking. The store keeps them cheap — a - * cache update and an enqueue onto its delta channel — and defers any suspending work to its own - * coroutine. + * callback (e.g. a jOOQ `TransactionListener`). The store's actions are cheap — a version-gated + * cache update and a `tryEmit` of the delta. */ interface TxContext { val transaction: T diff --git a/util/src/samples/kotlin/samples/StoreSamples.kt b/util/src/samples/kotlin/samples/StoreSamples.kt index 0858e36..53726e8 100644 --- a/util/src/samples/kotlin/samples/StoreSamples.kt +++ b/util/src/samples/kotlin/samples/StoreSamples.kt @@ -26,10 +26,9 @@ class StoreSamples { * A [CacheLoaderWriter] over a jOOQ `account` table whose `version` is drawn from a database * sequence, so the version is the store's durable commit order rather than an in-process counter. * - * The transactional operations ([write], [delete] and the read-modify-write [load]) are - * non-suspending: they run on the caller's blocking transaction (`tx.transaction`), already - * dispatched to [Dispatchers.IO] by [flowStoreTransaction]. Only the cache-miss read-through - * [load] is suspending, so it dispatches its own blocking jOOQ call to [Dispatchers.IO]. + * The transactional operations ([write], [delete] and the read-modify-write [load]) run on the + * caller's transaction via `tx.transaction`. The cache-miss read-through [load] has no + * transaction, so it runs its blocking jOOQ call on [Dispatchers.IO]. */ class JooqAccountStore(private val dsl: DSLContext) : CacheLoaderWriter { @@ -76,15 +75,11 @@ class StoreSamples { } /** - * Wires a [mutableFlowStore] that *participates in* the blocking jOOQ transactions the - * application owns — no R2DBC. Installing [FlowStorePublishingListener] once means every - * `dsl.transaction { … }` publishes the store's buffered deltas automatically on commit (or - * discards them on rollback), so callers write plain jOOQ transactions with no per-transaction - * wrapper. The store's mutations are non-suspending and run directly on the transaction. - * - * Moving funds between two accounts is one transaction: the locking read-modify-write - * (`store.get(key, config)`) and both writes run on it, and both deltas publish together on - * commit or neither on rollback. + * Wires a [mutableFlowStore] over a jOOQ `account` table and moves funds between two accounts in + * a single `dsl.transaction { … }`: the locking read-modify-write (`store.get(key, config)`) and + * both writes run on the transaction, and both deltas reach the stream together on commit or + * neither on rollback. [FlowStorePublishingListener], installed once on the [DSLContext], is what + * turns each commit into the delta publish. */ suspend fun jooqSample(rootDsl: DSLContext, scope: CoroutineScope) { val cache = @@ -94,8 +89,8 @@ class StoreSamples { val store = mutableFlowStore(JooqAccountStore(rootDsl), cache, txContext = Configuration::asTxContext) - // Install the publishing listener once; every transaction below then drains the buffered - // commit/rollback actions itself — no flowStoreTransaction wrapper to remember. + // Install the publishing listener once on the DSLContext; transactions opened from it run the + // store's buffered commit/rollback actions in their commit/rollback callbacks. val dsl = rootDsl .configuration() @@ -117,10 +112,10 @@ private const val COMMIT_ACTIONS = "datasourcex.flowstore.commit-actions" private const val ROLLBACK_ACTIONS = "datasourcex.flowstore.rollback-actions" /** - * Drains the store's buffered post-commit / rollback actions from within jOOQ's transaction - * lifecycle, so a plain `dsl.transaction { … }` publishes the deltas automatically. The actions are - * non-suspending (the store enqueues each delta and emits it from its own coroutine), so they run - * directly in these synchronous callbacks — no `runBlocking` and no blocked thread. + * Runs the store's buffered post-commit / rollback actions from within jOOQ's transaction + * lifecycle: [commitEnd] fires the commit actions ([COMMIT_ACTIONS]) and [rollbackEnd] the rollback + * actions ([ROLLBACK_ACTIONS]), each adapted from the transaction's [Configuration] by + * [asTxContext]. */ private object FlowStorePublishingListener : TransactionListener { override fun commitEnd(ctx: TransactionContext) = ctx.configuration().runActions(COMMIT_ACTIONS) @@ -131,8 +126,8 @@ private object FlowStorePublishingListener : TransactionListener { /** * Adapts a jOOQ transaction [Configuration] to a [TxContext], buffering the store's post-commit - * side effects in the transaction-scoped [Configuration.data] so [flowStoreTransaction] can run - * them after the commit or discard them on rollback. + * side effects in the transaction-scoped [Configuration.data] so [FlowStorePublishingListener] can + * run them after the commit or discard them on rollback. */ private fun Configuration.asTxContext(): TxContext = object : TxContext { From 1934d3998117173c4c7e2e377a413705d3fa8ad7 Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Tue, 2 Jun 2026 19:54:30 +0100 Subject: [PATCH 05/13] Review --- util/api/datasourcex-util.api | 18 ++++- .../datasourcex/util/cache/SuspendingCache.kt | 9 +-- .../util/store/AbstractFlowStore.kt | 62 ++------------ .../datasourcex/util/store/CacheWriter.kt | 5 +- .../datasourcex/util/store/FlowStore.kt | 30 +++---- .../datasourcex/util/store/FlowStoreCache.kt | 81 +++++++++++++++++++ .../util/store/MutableFlowStore.kt | 33 ++++---- .../datasourcex/util/store/TxContext.kt | 21 ++++- .../samples/kotlin/samples/StoreSamples.kt | 9 +-- .../util/store/FlowStoreCacheTest.kt | 56 +++++++++++++ .../datasourcex/util/store/FlowStoreTest.kt | 16 ++-- .../util/store/MutableFlowStoreTest.kt | 33 +++++--- .../datasourcex/util/store/TxContextTest.kt | 11 +++ 13 files changed, 252 insertions(+), 132 deletions(-) create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index 5e4d483..cd8cdc1 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -153,6 +153,7 @@ public class com/caplin/integration/datasourcex/util/cache/SuspendingCache { public final fun asyncCache ()Lcom/github/benmanes/caffeine/cache/AsyncCache; public final fun get (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getIfPresent (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + protected final fun getLoaderScope ()Lkotlinx/coroutines/CoroutineScope; public final fun invalidate (Ljava/lang/Object;)V public final fun put (Ljava/lang/Object;Ljava/lang/Object;)V } @@ -624,9 +625,20 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Fl public abstract fun valueFlow (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } +public final class com/caplin/integration/datasourcex/util/store/FlowStoreCache : com/caplin/integration/datasourcex/util/cache/SuspendingCache { + public fun (Lcom/github/benmanes/caffeine/cache/AsyncCache;Lkotlinx/coroutines/CoroutineScope;)V + public final fun loadIfNewer (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun putIfNewer (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/CacheEntry;)Lcom/caplin/integration/datasourcex/util/store/CacheEntry; + public final fun reflectIfNewer (Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent;)V +} + +public final class com/caplin/integration/datasourcex/util/store/FlowStoreCacheKt { + public static final fun buildFlowStoreCache (Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;)Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache; +} + public final class com/caplin/integration/datasourcex/util/store/FlowStoreKt { - public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlinx/coroutines/CoroutineScope;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; - public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlinx/coroutines/CoroutineScope;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache;Lkotlinx/coroutines/CoroutineScope;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache;Lkotlinx/coroutines/CoroutineScope;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; } public final class com/caplin/integration/datasourcex/util/store/Live : com/caplin/integration/datasourcex/util/store/CacheEntry { @@ -650,7 +662,7 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Mu } public final class com/caplin/integration/datasourcex/util/store/MutableFlowStoreKt { - public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache;Lkotlin/jvm/functions/Function1;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache;Lkotlin/jvm/functions/Function1;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; } public final class com/caplin/integration/datasourcex/util/store/Tombstone : com/caplin/integration/datasourcex/util/store/CacheEntry { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt index 0e99b87..cb2d443 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt @@ -11,16 +11,15 @@ import kotlinx.coroutines.future.future /** * A coroutine wrapper over Caffeine's [AsyncCache] with single-flight loading. Loads run in a child - * [SupervisorJob] of [scope] rather than the caller's coroutine, so one caller cancelling its [get] - * does not fail the shared computation, and a failed load surfaces only to that caller without - * cancelling [scope]. [scope]'s dispatcher decides where loads run, so it is required (no default): - * a blocking loader needs [kotlinx.coroutines.Dispatchers.IO], not the default dispatcher. + * [SupervisorJob] of [scope], so cancelling one [get] does not fail the shared load and a failed + * load surfaces only to its caller. [scope]'s dispatcher decides where loads run: a blocking loader + * needs [kotlinx.coroutines.Dispatchers.IO]. */ open class SuspendingCache( private val cache: AsyncCache, scope: CoroutineScope, ) { - private val loaderScope = + protected val loaderScope: CoroutineScope = CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) fun asyncCache(): AsyncCache = cache diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt index 8676ec9..3591837 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -1,8 +1,6 @@ package com.caplin.integration.datasourcex.util.store -import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent -import java.util.concurrent.CompletableFuture import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -14,13 +12,12 @@ import kotlinx.coroutines.flow.onSubscription internal const val DEFAULT_SIGNAL_BUFFER: Int = 256 /** - * Shared core for the store-backed [FlowStore] variants: a delta-only [signal] bus, a bounded hot - * set [cache] of [CacheEntry] values, and read-through on a miss. Entries carry versions so each - * entry's freshness can be compared against incoming deltas and concurrent read-through loads. + * Shared core for the store-backed [FlowStore] variants: a delta-only [signal] bus and a versioned + * hot-set [cache] read through to the store on a miss. */ internal abstract class AbstractFlowStore( protected val loader: CacheLoader, - protected val cache: SuspendingCache>, + protected val cache: FlowStoreCache, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, ) : FlowStore { @@ -33,62 +30,21 @@ internal abstract class AbstractFlowStore( override fun asFlow(): Flow> = signal.asSharedFlow() - override suspend fun get(key: K): V? = - (cache.getIfPresent(key) ?: loadAndCache(key))?.valueOrNull() - - /** - * Loads [key] through the store and caches it, but never regresses a fresher resident entry: a - * delta stream can legitimately run ahead of the store a read-through reads from. A resident - * [Tombstone] beats a null load, so a removed key is not re-read as absent. - */ - protected open suspend fun loadAndCache(key: K): CacheEntry? = - loader.load(key)?.let { cachePutIfNewer(key, Live(it.value, it.version)) } - ?: cache.getIfPresent(key) - - /** - * Atomically caches [candidate] unless a strictly-newer entry is already resident, returning the - * resident entry afterwards. The cache's per-key atomic compute closes the check-then-put race, - * so a concurrent writer (commit, read-through, or inbound delta) cannot be clobbered by a stale - * read. - */ - protected fun cachePutIfNewer(key: K, candidate: CacheEntry): CacheEntry { - val resident = - cache.asyncCache().asMap().merge(key, CompletableFuture.completedFuture(candidate)) { - oldFuture, - newFuture -> - val old = oldFuture.getNow(null) - if (old != null && old.version >= candidate.version) oldFuture else newFuture - } - return resident?.getNow(candidate) ?: candidate - } - - /** - * Reflects [event] onto a *resident* entry only, gating on version — the consumer counterpart to - * [cachePutIfNewer], which also inserts. A removal leaves a tombstone so a stale older - * read-through is rejected by version; non-resident keys are left for the next read-through. - */ - protected fun cacheReflectIfNewer(event: VersionedMapEvent) { - cache.asyncCache().asMap().computeIfPresent(event.key) { _, oldFuture -> - val old = oldFuture.getNow(null) - if (old != null && event.version > old.version) - CompletableFuture.completedFuture(event.toEntry()) - else oldFuture - } - } + override suspend fun get(key: K): V? = cache.loadIfNewer(key, loader::load)?.valueOrNull() override fun valueFlow(key: K): Flow = flow { var highest = Long.MIN_VALUE signal .onSubscription { - val initial = loadAndCache(key) + val initial = cache.loadIfNewer(key, loader::load) highest = initial?.version ?: Long.MIN_VALUE this@flow.emit(initial?.valueOrNull()) } .collect { event -> if (event.key == key && event.version > highest) { highest = event.version - this@flow.emit(event.valueOrNull()) + this@flow.emit(event.toEntry().valueOrNull()) } } } @@ -101,12 +57,6 @@ private fun CacheEntry.valueOrNull(): V? = is Tombstone -> null } -internal fun VersionedMapEvent<*, V>.valueOrNull(): V? = - when (this) { - is VersionedMapEvent.Upsert -> value - is VersionedMapEvent.Removed -> null - } - internal fun VersionedMapEvent<*, V>.toEntry(): CacheEntry = when (this) { is VersionedMapEvent.Upsert -> Live(value, version) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt index dc13daa..e29b7ed 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt @@ -2,9 +2,8 @@ package com.caplin.integration.datasourcex.util.store /** * Write half of the store SPI. Implementations enlist on [TxContext.transaction] and must not - * commit the transaction themselves. Each write assigns and returns the new version: the version is - * the store's commit order (a sequence, identity, or version column), never supplied by the caller, - * so it survives restarts and orders writes the way the store committed them. + * commit the transaction themselves. Each write assigns and returns the new version from the + * store's commit order (a sequence, identity, or version column); the caller never supplies it. * * Writes are **not** suspending: they run on the caller's transaction, which may be a blocking jOOQ * / JDBC transaction callback (`dsl.transaction { … }`). Dispatch the whole transaction to a diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index bdf3cfb..558cdc0 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -1,6 +1,5 @@ package com.caplin.integration.datasourcex.util.store -import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -10,16 +9,10 @@ import kotlinx.coroutines.launch * Read view of a store-backed map. Exposes the delta stream and per-key access only; there is no * full-map snapshot, so values are read through to the store on a miss. * - * The value [V] must be an **aggregate root**: the complete state owned under a key, treated as an - * opaque whole. Every write replaces a key's value entirely — there are no field-level or partial - * updates and no merge — each published [VersionedMapEvent.Upsert] carries the full value, and a - * cache miss reads the whole value back through the store. Together with single-writer-per-key - * ownership (exactly one process writes a given key, and it serialises writes to that key) this is - * what makes a key's version sequence totally ordered. - * - * It is therefore unsuitable for values updated field-by-field by multiple writers, partial - * projections that must be merged, or values large enough that republishing the whole value on - * every change is too costly. + * The value [V] must be an **aggregate root**: every write replaces a key's value as a whole (no + * field-level or partial updates), so each [VersionedMapEvent.Upsert] carries the full value and a + * miss reads the whole value back. With single-writer-per-key ownership this makes a key's version + * sequence totally ordered. */ interface FlowStore { /** The live, delta-only stream of versioned mutations. */ @@ -28,6 +21,10 @@ interface FlowStore { /** The latest value for [key], starting from a read-through load then following the stream. */ fun valueFlow(key: K): Flow + /** + * The latest value for [key]: cache-first, reading through to the store on a miss. Not ordered + * against [asFlow] — a value just published as a delta may not be visible here yet. + */ suspend fun get(key: K): V? } @@ -38,9 +35,8 @@ interface FlowStore { * that transaction commits. * * The owner must **serialise writes to a given key** (single-writer-per-key): the version is the - * store's commit order, so concurrent unserialised writes to the same key would let the persisted - * row, not just the cache, settle on the wrong version. [V] must be an aggregate root — see - * [FlowStore]. + * store's commit order, so unserialised concurrent writes to one key would settle on the wrong + * version. [V] must be an aggregate root — see [FlowStore]. */ interface MutableFlowStore : FlowStore { /** @@ -65,14 +61,14 @@ interface MutableFlowStore : FlowStore { fun flowStore( loader: CacheLoader, inbound: Flow>, - cache: SuspendingCache>, + cache: FlowStoreCache, scope: CoroutineScope, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, ): FlowStore = FlowStoreImpl(loader, cache, inbound, scope, bufferCapacity) internal class FlowStoreImpl( loader: CacheLoader, - cache: SuspendingCache>, + cache: FlowStoreCache, inbound: Flow>, scope: CoroutineScope, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, @@ -81,7 +77,7 @@ internal class FlowStoreImpl( init { scope.launch { inbound.collect { event -> - cacheReflectIfNewer(event) + cache.reflectIfNewer(event) signal.emit(event) } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt new file mode 100644 index 0000000..f9e9b46 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt @@ -0,0 +1,81 @@ +package com.caplin.integration.datasourcex.util.store + +import com.caplin.integration.datasourcex.util.cache.SuspendingCache +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.github.benmanes.caffeine.cache.AsyncCache +import com.github.benmanes.caffeine.cache.Caffeine +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch + +/** + * A [SuspendingCache] of versioned [CacheEntry] values that owns the store's version gating. The + * Caffeine map only ever holds completed entries; concurrent read-through misses for a key are + * coalesced through [inFlight] into a single load. + */ +class FlowStoreCache( + cache: AsyncCache>, + scope: CoroutineScope, +) : SuspendingCache>(cache, scope) { + + private val inFlight = ConcurrentHashMap?>>() + + /** + * Returns the resident entry, or single-flight loads it via [load] on a miss and caches it, + * without regressing a fresher entry written meanwhile. A null load caches nothing. + */ + suspend fun loadIfNewer(key: K, load: suspend (K) -> Versioned?): CacheEntry? { + getIfPresent(key)?.let { + return it + } + val mine = CompletableFuture?>() + inFlight.putIfAbsent(key, mine)?.let { + return it.await() + } + loaderScope.launch { + try { + val loaded = load(key) + mine.complete( + loaded?.let { putIfNewer(key, Live(it.value, it.version)) } ?: getIfPresent(key) + ) + } catch (t: Throwable) { + mine.completeExceptionally(t) + } finally { + inFlight.remove(key, mine) + } + } + return mine.await() + } + + /** + * Atomically caches [candidate] unless a strictly-newer entry is already resident, returning the + * resident entry. The per-key atomic compute closes the check-then-put race. + */ + fun putIfNewer(key: K, candidate: CacheEntry): CacheEntry { + val merged = + asyncCache().asMap().merge(key, CompletableFuture.completedFuture(candidate)) { old, new -> + val current = old.getNow(null) + if (current != null && current.version >= candidate.version) old else new + } + return merged?.getNow(candidate) ?: candidate + } + + /** + * Applies [event] to a resident entry only, gated on version; absent keys await the next read. + */ + fun reflectIfNewer(event: VersionedMapEvent) { + asyncCache().asMap().computeIfPresent(event.key) { _, old -> + val current = old.getNow(null) + if (current != null && event.version > current.version) + CompletableFuture.completedFuture(event.toEntry()) + else old + } + } +} + +/** Builds a [FlowStoreCache] from a configured Caffeine builder. */ +fun Caffeine>.buildFlowStoreCache( + scope: CoroutineScope +): FlowStoreCache = FlowStoreCache(buildAsync(), scope) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index 2d3cec7..219449b 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -1,6 +1,5 @@ package com.caplin.integration.datasourcex.util.store -import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent /** @@ -10,19 +9,19 @@ import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent * `Connection`, …), which [txContext] adapts into a [TxContext] so the write can enlist on it and * register its publish. Mutations are non-suspending and run on that transaction; on commit the * store refreshes its cache and `tryEmit`s the delta onto its stream. The stream's buffer is - * unbounded, so the commit callback never suspends on stream backpressure — a slow consumer grows - * the buffer instead. [store] assigns each write's version. [V] must be an aggregate root — see - * [FlowStore]. + * unbounded so the commit callback never suspends on backpressure; a permanently slow consumer + * therefore grows the buffer without bound (eventually OOM) rather than blocking the committer. + * [store] assigns each write's version. [V] must be an aggregate root — see [FlowStore]. */ fun mutableFlowStore( store: CacheLoaderWriter, - cache: SuspendingCache>, + cache: FlowStoreCache, txContext: (T) -> TxContext, ): MutableFlowStore = MutableFlowStoreImpl(store, cache, txContext) internal class MutableFlowStoreImpl( private val writer: CacheLoaderWriter, - cache: SuspendingCache>, + cache: FlowStoreCache, private val txContext: (T) -> TxContext, // Unbounded buffer so the commit callback's tryEmit always succeeds without suspending: a slow // consumer grows the buffer, never blocks the committing thread. @@ -39,11 +38,14 @@ internal class MutableFlowStoreImpl( override fun putAll(from: Map, tx: T) { val ctx = txContext(tx) val versions = writer.writeAll(from, ctx) - ctx.onCommitEnd { - from.forEach { (key, value) -> - publish(VersionedMapEvent.Upsert(key, value, versions.getValue(key))) - } - } + // Build the deltas eagerly, before commit, so a writer that omits a key fails the transaction + // here rather than throwing from the post-commit callback after the writes are durable. + val deltas = + from.map { (key, value) -> + val version = versions[key] ?: error("writeAll returned no version for key $key") + VersionedMapEvent.Upsert(key, value, version) + } + ctx.onCommitEnd { deltas.forEach(::publish) } } override fun remove(key: K, tx: T) { @@ -53,13 +55,12 @@ internal class MutableFlowStoreImpl( } /** - * Emits [event] before refreshing the cache: the (unbounded) emit always accepts the delta, so - * the owner cache can never sit ahead of an unpublished delta. The cache write is version-gated - * so an interleaved post-commit reflection cannot regress it. Both steps are non-suspending, so - * this runs in full inside the synchronous commit callback. + * Emits [event] then updates the cache: the unbounded emit always accepts the delta, so the cache + * never sits ahead of an unpublished delta. Both steps are non-suspending and run inside the + * synchronous commit callback. */ private fun publish(event: VersionedMapEvent) { signal.tryEmit(event) - cachePutIfNewer(event.key, event.toEntry()) + cache.putIfNewer(event.key, event.toEntry()) } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt index 8b0ecbb..68af25a 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt @@ -9,8 +9,7 @@ import java.util.concurrent.atomic.AtomicBoolean * via [onCommitEnd] (and optional [onRollback] cleanup). * * Registered actions are **non-suspending** so they can run inside a synchronous commit/rollback - * callback (e.g. a jOOQ `TransactionListener`). The store's actions are cheap — a version-gated - * cache update and a `tryEmit` of the delta. + * callback (e.g. a jOOQ `TransactionListener`). */ interface TxContext { val transaction: T @@ -41,7 +40,7 @@ class AutoCommitTxContext(override val transaction: T) : TxContext { /** Fires the registered commit actions once; reusing a completed context throws. */ fun commit() { check(completed.compareAndSet(false, true)) { "Transaction already completed" } - for (action in commitActions) action() + runAll(commitActions) } /** @@ -49,6 +48,20 @@ class AutoCommitTxContext(override val transaction: T) : TxContext { */ fun rollback() { if (!completed.compareAndSet(false, true)) return - for (action in rollbackActions) action() + runAll(rollbackActions) + } + + // Run every action even if some throw, so one failure cannot strand the rest; the first failure + // propagates with the others suppressed. + private fun runAll(actions: List<() -> Unit>) { + var failure: Throwable? = null + for (action in actions) { + try { + action() + } catch (t: Throwable) { + if (failure == null) failure = t else failure.addSuppressed(t) + } + } + failure?.let { throw it } } } diff --git a/util/src/samples/kotlin/samples/StoreSamples.kt b/util/src/samples/kotlin/samples/StoreSamples.kt index 53726e8..095b2b3 100644 --- a/util/src/samples/kotlin/samples/StoreSamples.kt +++ b/util/src/samples/kotlin/samples/StoreSamples.kt @@ -1,10 +1,9 @@ package samples -import com.caplin.integration.datasourcex.util.cache.buildSuspending -import com.caplin.integration.datasourcex.util.store.CacheEntry import com.caplin.integration.datasourcex.util.store.CacheLoaderWriter import com.caplin.integration.datasourcex.util.store.TxContext import com.caplin.integration.datasourcex.util.store.Versioned +import com.caplin.integration.datasourcex.util.store.buildFlowStoreCache import com.caplin.integration.datasourcex.util.store.mutableFlowStore import com.github.benmanes.caffeine.cache.Caffeine import kotlinx.coroutines.CoroutineScope @@ -16,6 +15,8 @@ import org.jooq.Record import org.jooq.TransactionContext import org.jooq.TransactionListener import org.jooq.impl.DefaultTransactionListenerProvider +import samples.FlowStorePublishingListener.commitEnd +import samples.FlowStorePublishingListener.rollbackEnd class StoreSamples { @@ -83,9 +84,7 @@ class StoreSamples { */ suspend fun jooqSample(rootDsl: DSLContext, scope: CoroutineScope) { val cache = - Caffeine.newBuilder() - .maximumSize(10_000) - .buildSuspending>(scope) + Caffeine.newBuilder().maximumSize(10_000).buildFlowStoreCache(scope) val store = mutableFlowStore(JooqAccountStore(rootDsl), cache, txContext = Configuration::asTxContext) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt new file mode 100644 index 0000000..b6d1b9c --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt @@ -0,0 +1,56 @@ +package com.caplin.integration.datasourcex.util.store + +import com.github.benmanes.caffeine.cache.Caffeine +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope + +// Real concurrency / real dispatchers, so these opt out of the project's coroutineTestScope. +class FlowStoreCacheTest : + FunSpec({ + val scope = CoroutineScope(Dispatchers.Default) + afterSpec { scope.cancel() } + + test("concurrent reads on a miss share a single load").config(coroutineTestScope = false) { + val calls = AtomicInteger(0) + val gate = CompletableDeferred() + val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) + val load: suspend (String) -> Versioned? = { key -> + calls.incrementAndGet() + gate.await() + Versioned("v:$key", 1L) + } + + coroutineScope { + val reads = List(20) { async { cache.loadIfNewer("k", load) } } + gate.complete(Unit) + reads.awaitAll().forEach { it shouldBe Live("v:k", 1L) } + } + + calls.get() shouldBe 1 + } + + test("a load that finds nothing caches nothing") { + val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) + + cache.loadIfNewer("missing") { null }.shouldBeNull() + cache.getIfPresent("missing").shouldBeNull() + } + + test("putIfNewer keeps the strictly-newer resident entry") { + val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) + + cache.putIfNewer("k", Live("v5", 5L)) shouldBe Live("v5", 5L) + cache.putIfNewer("k", Live("v3", 3L)) shouldBe Live("v5", 5L) // older rejected + cache.putIfNewer("k", Live("v5b", 5L)) shouldBe Live("v5", 5L) // equal rejected + cache.putIfNewer("k", Live("v9", 9L)) shouldBe Live("v9", 9L) + } + }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index 3485b06..69c27f8 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -1,7 +1,6 @@ package com.caplin.integration.datasourcex.util.store import app.cash.turbine.test -import com.caplin.integration.datasourcex.util.cache.buildSuspending import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Caffeine import io.kotest.core.spec.style.FunSpec @@ -24,7 +23,7 @@ class FlowStoreTest : flowStore( store, emptyFlow(), - Caffeine.newBuilder().buildSuspending(backgroundScope), + Caffeine.newBuilder().buildFlowStoreCache(backgroundScope), backgroundScope, ) @@ -36,8 +35,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -61,8 +59,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v2", 2L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -80,8 +77,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -101,7 +97,7 @@ class FlowStoreTest : val owner = mutableFlowStore( store, - Caffeine.newBuilder().buildSuspending(backgroundScope), + Caffeine.newBuilder().buildFlowStoreCache(backgroundScope), txContext = inMemoryTxContext, ) fun commit(action: (InMemoryTx) -> Unit) = InMemoryTx().also { action(it) }.commit() @@ -114,7 +110,7 @@ class FlowStoreTest : flowStore( store, ownerDeltas, - Caffeine.newBuilder().buildSuspending(backgroundScope), + Caffeine.newBuilder().buildFlowStoreCache(backgroundScope), backgroundScope, ) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt index b057d9c..dd6a572 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -1,7 +1,6 @@ package com.caplin.integration.datasourcex.util.store import app.cash.turbine.test -import com.caplin.integration.datasourcex.util.cache.buildSuspending import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Caffeine import io.kotest.assertions.throwables.shouldThrow @@ -17,8 +16,7 @@ class MutableFlowStoreTest : FunSpec({ test("put publishes a versioned delta and updates the cache only on commit") { val backing = InMemoryCacheLoaderWriter() - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -35,8 +33,7 @@ class MutableFlowStoreTest : test("rollback publishes nothing and the next write gets a strictly greater version") { val backing = InMemoryCacheLoaderWriter() - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -55,8 +52,7 @@ class MutableFlowStoreTest : test("a writer failure propagates and leaves the cache and stream untouched") { val backing = mockk>() every { backing.write(any(), any(), any()) } throws RuntimeException("boom") - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -68,8 +64,7 @@ class MutableFlowStoreTest : test("get reflects the committed value only after commit") { val backing = InMemoryCacheLoaderWriter() - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) InMemoryTx().also { @@ -89,8 +84,7 @@ class MutableFlowStoreTest : test("get(key, tx) reads through the store within the transaction, bypassing the cache") { val backing = InMemoryCacheLoaderWriter() backing.seed("k", "fresh-in-db", 9L) - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) // A stale, lower-versioned entry sits in the cache. cache.asyncCache().put("k", CompletableFuture.completedFuture(Live("stale-in-cache", 1L))) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) @@ -101,8 +95,7 @@ class MutableFlowStoreTest : test("remove publishes a Removed delta and tombstones the cache") { val backing = InMemoryCacheLoaderWriter() - val cache = - Caffeine.newBuilder().buildSuspending>(backgroundScope) + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -121,4 +114,18 @@ class MutableFlowStoreTest : store.get("k").shouldBeNull() } } + + test("putAll fails before commit when the writer omits a version, publishing nothing") { + val backing = mockk>() + every { backing.writeAll(any(), any()) } returns mapOf("a" to 1L) // "b" missing + val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + + store.asFlow().test { + val tx = InMemoryTx() + shouldThrow { store.putAll(mapOf("a" to "A", "b" to "B"), tx) } + tx.commit() + expectNoEvents() + } + } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt index 4ec4749..9771d60 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt @@ -26,4 +26,15 @@ class TxContextTest : tx.rollback() rollbacks shouldBe 0 } + + test("commit runs every action even if one throws, then rethrows") { + val log = mutableListOf() + val tx = AutoCommitTxContext(Unit) + tx.onCommitEnd { log += "a" } + tx.onCommitEnd { throw RuntimeException("boom") } + tx.onCommitEnd { log += "c" } + + shouldThrow { tx.commit() } + log shouldBe listOf("a", "c") + } }) From cfa74f06706b9eb129fe3d5f97e6206d38d0cc60 Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Tue, 2 Jun 2026 20:44:18 +0100 Subject: [PATCH 06/13] Fix cache regression in reflectIfNewer and cancellation hang in loadIfNewer --- .../datasourcex/util/store/FlowStoreCache.kt | 29 +++++++++--- .../util/store/FlowStoreCacheTest.kt | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt index f9e9b46..0b3afb5 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt @@ -6,6 +6,7 @@ import com.github.benmanes.caffeine.cache.AsyncCache import com.github.benmanes.caffeine.cache.Caffeine import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.future.await import kotlinx.coroutines.launch @@ -34,7 +35,7 @@ class FlowStoreCache( inFlight.putIfAbsent(key, mine)?.let { return it.await() } - loaderScope.launch { + val job = loaderScope.launch { try { val loaded = load(key) mine.complete( @@ -46,6 +47,13 @@ class FlowStoreCache( inFlight.remove(key, mine) } } + job.invokeOnCompletion { throwable -> + if (throwable != null) { + mine.completeExceptionally(throwable) + } else if (!mine.isDone) { + mine.completeExceptionally(CancellationException("Job completed without completing future")) + } + } return mine.await() } @@ -63,14 +71,21 @@ class FlowStoreCache( } /** - * Applies [event] to a resident entry only, gated on version; absent keys await the next read. + * Applies [event] to a resident or in-flight loading entry only, gated on version; other absent + * keys await the next read. */ fun reflectIfNewer(event: VersionedMapEvent) { - asyncCache().asMap().computeIfPresent(event.key) { _, old -> - val current = old.getNow(null) - if (current != null && event.version > current.version) - CompletableFuture.completedFuture(event.toEntry()) - else old + asyncCache().asMap().compute(event.key) { _, old -> + val current = old?.getNow(null) + if (current != null) { + if (event.version > current.version) CompletableFuture.completedFuture(event.toEntry()) else old + } else if (old != null) { + old + } else if (inFlight.containsKey(event.key)) { + CompletableFuture.completedFuture(event.toEntry()) + } else { + null + } } } } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt index b6d1b9c..500c4b6 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt @@ -1,10 +1,13 @@ package com.caplin.integration.datasourcex.util.store +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Caffeine +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -53,4 +56,46 @@ class FlowStoreCacheTest : cache.putIfNewer("k", Live("v5b", 5L)) shouldBe Live("v5", 5L) // equal rejected cache.putIfNewer("k", Live("v9", 9L)) shouldBe Live("v9", 9L) } + + test("reflectIfNewer updates the cache during an in-flight load, preventing regression").config( + coroutineTestScope = false + ) { + val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) + val loadGate = CompletableDeferred() + val resultGate = CompletableDeferred() + + val loadJob = async { + cache.loadIfNewer("k") { + loadGate.complete(Unit) + resultGate.await() + Versioned("v5", 5L) + } + } + + // Wait until the load is executing and in-flight + loadGate.await() + + // Call reflectIfNewer with a newer version (Version 6) while load is in-flight + cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v6", 6L)) + + // Complete the load (which yields Version 5) + resultGate.complete(Unit) + loadJob.await() + + // Verify that the cache keeps the newer Version 6 and did not regress to Version 5 + cache.getIfPresent("k") shouldBe Live("v6", 6L) + } + + test("loadIfNewer throws CancellationException if the scope is cancelled").config( + coroutineTestScope = false + ) { + val deadScope = CoroutineScope(Dispatchers.Default) + deadScope.cancel() + + val cache = Caffeine.newBuilder().buildFlowStoreCache(deadScope) + + shouldThrow { + cache.loadIfNewer("k") { Versioned("v", 1L) } + } + } }) From ecc08d45679d3146a5dc69cf626c967ae2e919fb Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Wed, 3 Jun 2026 11:21:54 +0100 Subject: [PATCH 07/13] Remove unused classes and functions and remove suspend (add Async version instead) --- util/api/datasourcex-util.api | 71 +++++--------- .../datasourcex/util/cache/SuspendingCache.kt | 61 ------------ .../util/store/AbstractFlowStore.kt | 27 +++++- .../datasourcex/util/store/AsyncFlowStore.kt | 66 +++++++++++++ .../datasourcex/util/store/CacheLoader.kt | 16 +--- .../util/store/CacheLoaderWriter.kt | 7 +- .../datasourcex/util/store/CacheWriter.kt | 9 -- .../datasourcex/util/store/FlowStore.kt | 46 ++++----- .../datasourcex/util/store/FlowStoreCache.kt | 96 ++++--------------- .../util/store/MutableFlowStore.kt | 59 ++++++++++-- .../datasourcex/util/store/TxContext.kt | 10 +- .../samples/kotlin/samples/StoreSamples.kt | 16 ++-- .../util/cache/SuspendingCacheTest.kt | 88 ----------------- .../util/store/FlowStoreCacheTest.kt | 84 ++++------------ .../datasourcex/util/store/FlowStoreTest.kt | 18 ++-- .../util/store/InMemoryCacheLoaderWriter.kt | 11 ++- .../store/InMemoryCacheLoaderWriterTest.kt | 6 -- .../util/store/MutableFlowStoreTest.kt | 51 ++++++++-- 18 files changed, 283 insertions(+), 459 deletions(-) delete mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt delete mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index cd8cdc1..0afe5e4 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -143,26 +143,6 @@ public final class com/caplin/integration/datasourcex/util/TimeoutKt { public static final fun withTimeout-KLykuaI (JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public final class com/caplin/integration/datasourcex/util/cache/LoadingSuspendingCache : com/caplin/integration/datasourcex/util/cache/SuspendingCache { - public fun (Lcom/github/benmanes/caffeine/cache/AsyncCache;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function2;)V - public final fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public class com/caplin/integration/datasourcex/util/cache/SuspendingCache { - public fun (Lcom/github/benmanes/caffeine/cache/AsyncCache;Lkotlinx/coroutines/CoroutineScope;)V - public final fun asyncCache ()Lcom/github/benmanes/caffeine/cache/AsyncCache; - public final fun get (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun getIfPresent (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - protected final fun getLoaderScope ()Lkotlinx/coroutines/CoroutineScope; - public final fun invalidate (Ljava/lang/Object;)V - public final fun put (Ljava/lang/Object;Ljava/lang/Object;)V -} - -public final class com/caplin/integration/datasourcex/util/cache/SuspendingCacheKt { - public static final fun buildSuspending (Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;)Lcom/caplin/integration/datasourcex/util/cache/SuspendingCache; - public static final fun buildSuspending (Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlin/jvm/functions/Function2;)Lcom/caplin/integration/datasourcex/util/cache/LoadingSuspendingCache; -} - public final class com/caplin/integration/datasourcex/util/flow/BufferKt { public static final fun bufferingDebounce (Lkotlinx/coroutines/flow/Flow;J)Lkotlinx/coroutines/flow/Flow; public static final fun bufferingDebounce (Lkotlinx/coroutines/flow/Flow;Ljava/time/Duration;)Lkotlinx/coroutines/flow/Flow; @@ -571,6 +551,19 @@ public final class com/caplin/integration/datasourcex/util/serialization/jackson public fun toObject (Ltools/jackson/databind/JsonNode;Ljava/lang/Class;)Ljava/lang/Object; } +public abstract interface class com/caplin/integration/datasourcex/util/store/AsyncFlowStore { + public abstract fun asFlow ()Lkotlinx/coroutines/flow/SharedFlow; + public abstract fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun valueFlow (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/AsyncMutableFlowStore : com/caplin/integration/datasourcex/util/store/AsyncFlowStore { + public abstract fun get (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun putAll (Ljava/util/Map;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun remove (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class com/caplin/integration/datasourcex/util/store/AutoCommitTxContext : com/caplin/integration/datasourcex/util/store/TxContext { public fun (Ljava/lang/Object;)V public final fun commit ()V @@ -585,14 +578,7 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Ca } public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoader { - public abstract fun load (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun loadAll (Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun loadAllKeys ()Lkotlinx/coroutines/flow/Flow; -} - -public final class com/caplin/integration/datasourcex/util/store/CacheLoader$DefaultImpls { - public static fun loadAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static fun loadAllKeys (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;)Lkotlinx/coroutines/flow/Flow; + public abstract fun load (Ljava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Versioned; } public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter : com/caplin/integration/datasourcex/util/store/CacheLoader, com/caplin/integration/datasourcex/util/store/CacheWriter { @@ -600,45 +586,30 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Ca } public final class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter$DefaultImpls { - public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; public static fun load (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Lcom/caplin/integration/datasourcex/util/store/Versioned; - public static fun loadAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Collection;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static fun loadAllKeys (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;)Lkotlinx/coroutines/flow/Flow; public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; } public abstract interface class com/caplin/integration/datasourcex/util/store/CacheWriter { public abstract fun delete (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J - public fun deleteAll (Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J public fun writeAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; } public final class com/caplin/integration/datasourcex/util/store/CacheWriter$DefaultImpls { - public static fun deleteAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Collection;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; } public abstract interface class com/caplin/integration/datasourcex/util/store/FlowStore { - public abstract fun asFlow ()Lkotlinx/coroutines/flow/Flow; - public abstract fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun asFlow ()Lkotlinx/coroutines/flow/SharedFlow; + public abstract fun get (Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getAsync ()Lcom/caplin/integration/datasourcex/util/store/AsyncFlowStore; public abstract fun valueFlow (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } -public final class com/caplin/integration/datasourcex/util/store/FlowStoreCache : com/caplin/integration/datasourcex/util/cache/SuspendingCache { - public fun (Lcom/github/benmanes/caffeine/cache/AsyncCache;Lkotlinx/coroutines/CoroutineScope;)V - public final fun loadIfNewer (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun putIfNewer (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/CacheEntry;)Lcom/caplin/integration/datasourcex/util/store/CacheEntry; - public final fun reflectIfNewer (Lcom/caplin/integration/datasourcex/util/flow/VersionedMapEvent;)V -} - -public final class com/caplin/integration/datasourcex/util/store/FlowStoreCacheKt { - public static final fun buildFlowStoreCache (Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;)Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache; -} - public final class com/caplin/integration/datasourcex/util/store/FlowStoreKt { - public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache;Lkotlinx/coroutines/CoroutineScope;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; - public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache;Lkotlinx/coroutines/CoroutineScope;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; } public final class com/caplin/integration/datasourcex/util/store/Live : com/caplin/integration/datasourcex/util/store/CacheEntry { @@ -656,13 +627,15 @@ public final class com/caplin/integration/datasourcex/util/store/Live : com/capl public abstract interface class com/caplin/integration/datasourcex/util/store/MutableFlowStore : com/caplin/integration/datasourcex/util/store/FlowStore { public abstract fun get (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getAsync ()Lcom/caplin/integration/datasourcex/util/store/AsyncMutableFlowStore; public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V public abstract fun putAll (Ljava/util/Map;Ljava/lang/Object;)V public abstract fun remove (Ljava/lang/Object;Ljava/lang/Object;)V } public final class com/caplin/integration/datasourcex/util/store/MutableFlowStoreKt { - public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/caplin/integration/datasourcex/util/store/FlowStoreCache;Lkotlin/jvm/functions/Function1;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; } public final class com/caplin/integration/datasourcex/util/store/Tombstone : com/caplin/integration/datasourcex/util/store/CacheEntry { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt deleted file mode 100644 index cb2d443..0000000 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCache.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.caplin.integration.datasourcex.util.cache - -import com.github.benmanes.caffeine.cache.AsyncCache -import com.github.benmanes.caffeine.cache.Caffeine -import java.util.concurrent.CompletableFuture -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.future.await -import kotlinx.coroutines.future.future - -/** - * A coroutine wrapper over Caffeine's [AsyncCache] with single-flight loading. Loads run in a child - * [SupervisorJob] of [scope], so cancelling one [get] does not fail the shared load and a failed - * load surfaces only to its caller. [scope]'s dispatcher decides where loads run: a blocking loader - * needs [kotlinx.coroutines.Dispatchers.IO]. - */ -open class SuspendingCache( - private val cache: AsyncCache, - scope: CoroutineScope, -) { - protected val loaderScope: CoroutineScope = - CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) - - fun asyncCache(): AsyncCache = cache - - suspend fun getIfPresent(key: K): V? = cache.getIfPresent(key)?.await() - - /** Returns the cached value, loading it (single-flight) via [loader] on a miss. */ - suspend fun get(key: K, loader: suspend (K) -> V): V = - cache.get(key) { k, _ -> loaderScope.future { loader(k) } }.await() - - /** Inserts a value directly, bypassing the loader. */ - fun put(key: K, value: V) { - cache.put(key, CompletableFuture.completedFuture(value)) - } - - fun invalidate(key: K) { - cache.synchronous().invalidate(key) - } -} - -/** A [SuspendingCache] with a fixed [loader] applied on every miss. */ -class LoadingSuspendingCache( - cache: AsyncCache, - scope: CoroutineScope, - private val loader: suspend (K) -> V, -) : SuspendingCache(cache, scope) { - suspend fun get(key: K): V = get(key, loader) -} - -/** Builds a [SuspendingCache] from a configured Caffeine builder. */ -fun Caffeine.buildSuspending( - scope: CoroutineScope -): SuspendingCache = SuspendingCache(buildAsync(), scope) - -/** Builds a [LoadingSuspendingCache] with a fixed suspend [loader]. */ -fun Caffeine.buildSuspending( - scope: CoroutineScope, - loader: suspend (K) -> V, -): LoadingSuspendingCache = LoadingSuspendingCache(buildAsync(), scope, loader) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt index 3591837..6981721 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -1,23 +1,28 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.withContext internal const val DEFAULT_SIGNAL_BUFFER: Int = 256 /** * Shared core for the store-backed [FlowStore] variants: a delta-only [signal] bus and a versioned - * hot-set [cache] read through to the store on a miss. + * hot-set [cache] read through to the store on a miss. The blocking read-through load runs on + * [dispatcher]. */ internal abstract class AbstractFlowStore( protected val loader: CacheLoader, protected val cache: FlowStoreCache, + protected val dispatcher: CoroutineDispatcher, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, ) : FlowStore { @@ -28,16 +33,30 @@ internal abstract class AbstractFlowStore( onBufferOverflow = BufferOverflow.SUSPEND, ) - override fun asFlow(): Flow> = signal.asSharedFlow() + override fun asFlow(): SharedFlow> = signal.asSharedFlow() - override suspend fun get(key: K): V? = cache.loadIfNewer(key, loader::load)?.valueOrNull() + override val async: AsyncFlowStore by lazy { AsyncFlowStoreImpl(this) } + + // Blocking on a miss; the caller dispatches it, as with the transactional operations. + override fun get(key: K): V? = readThrough(key)?.valueOrNull() + + private fun readThrough(key: K): CacheEntry? = + cache.getIfPresent(key) ?: cache.getOrLoad(key, loader::load) + + // Suspending [get] for the [async] view: a cache hit returns inline; only a miss is dispatched. + internal suspend fun getSuspending(key: K): V? { + cache.getIfPresent(key)?.let { + return it.valueOrNull() + } + return withContext(dispatcher) { cache.getOrLoad(key, loader::load) }?.valueOrNull() + } override fun valueFlow(key: K): Flow = flow { var highest = Long.MIN_VALUE signal .onSubscription { - val initial = cache.loadIfNewer(key, loader::load) + val initial = withContext(dispatcher) { readThrough(key) } highest = initial?.version ?: Long.MIN_VALUE this@flow.emit(initial?.valueOrNull()) } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt new file mode 100644 index 0000000..97dd741 --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt @@ -0,0 +1,66 @@ +package com.caplin.integration.datasourcex.util.store + +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.withContext + +/** + * Suspending view of a [FlowStore]: [get] runs the blocking read-through on the store's dispatcher, + * short-circuiting a cache hit inline so only a miss is dispatched. + */ +interface AsyncFlowStore { + fun asFlow(): SharedFlow> + + fun valueFlow(key: K): Flow + + suspend fun get(key: K): V? +} + +/** + * Suspending view of a [MutableFlowStore]: each call dispatches its blocking work to the store's + * dispatcher. The mutations take the caller's transaction handle [T], so use this where [T] + * tolerates use from the dispatcher (a transaction managed across threads with serial access); + * where the transaction is driven by a thread-bound, non-suspending callback, use the + * non-suspending [MutableFlowStore] instead. + */ +interface AsyncMutableFlowStore : AsyncFlowStore { + suspend fun get(key: K, tx: T): V? + + suspend fun put(key: K, value: V, tx: T) + + suspend fun putAll(from: Map, tx: T) + + suspend fun remove(key: K, tx: T) +} + +internal class AsyncFlowStoreImpl(private val store: AbstractFlowStore) : + AsyncFlowStore { + override fun asFlow(): SharedFlow> = store.asFlow() + + override fun valueFlow(key: K): Flow = store.valueFlow(key) + + override suspend fun get(key: K): V? = store.getSuspending(key) +} + +internal class AsyncMutableFlowStoreImpl( + private val store: MutableFlowStoreImpl, + private val dispatcher: CoroutineDispatcher, +) : AsyncMutableFlowStore { + override fun asFlow(): SharedFlow> = store.asFlow() + + override fun valueFlow(key: K): Flow = store.valueFlow(key) + + override suspend fun get(key: K): V? = store.getSuspending(key) + + override suspend fun get(key: K, tx: T): V? = withContext(dispatcher) { store.get(key, tx) } + + override suspend fun put(key: K, value: V, tx: T) = + withContext(dispatcher) { store.put(key, value, tx) } + + override suspend fun putAll(from: Map, tx: T) = + withContext(dispatcher) { store.putAll(from, tx) } + + override suspend fun remove(key: K, tx: T) = withContext(dispatcher) { store.remove(key, tx) } +} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt index 6ad9acf..5563780 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt @@ -1,20 +1,6 @@ package com.caplin.integration.datasourcex.util.store -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow - /** Read half of the store SPI. */ interface CacheLoader { - suspend fun load(key: K): Versioned? - - /** Loads many keys. The default issues one sequential [load] per key; override to batch. */ - suspend fun loadAll(keys: Collection): Map> = buildMap { - for (key in keys) load(key)?.let { put(key, it) } - } - - /** - * Streams every known key, for warm-up / enumeration rather than the hot path. The default - * enumerates nothing; override to support warm-up. - */ - fun loadAllKeys(): Flow = emptyFlow() + fun load(key: K): Versioned? } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt index c1c287d..7e442c8 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt @@ -4,10 +4,9 @@ package com.caplin.integration.datasourcex.util.store interface CacheLoaderWriter : CacheLoader, CacheWriter { /** * Reads [key]'s current persisted value within [tx] for read-modify-write, e.g. a locking `SELECT - * … FOR UPDATE` on the transaction's connection. Like the writes it is non-suspending, so it runs - * on the caller's blocking transaction; override it to support [MutableFlowStore.get] within a - * transaction (the default throws). The read should see the transaction's own uncommitted writes - * and serialise concurrent writers. + * … FOR UPDATE` on the transaction's connection. Override it to support [MutableFlowStore.get] + * within a transaction (the default throws); the read should see the transaction's own + * uncommitted writes and serialise concurrent writers. */ fun load(key: K, tx: TxContext): Versioned? = throw UnsupportedOperationException( diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt index e29b7ed..84d3dee 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt @@ -4,10 +4,6 @@ package com.caplin.integration.datasourcex.util.store * Write half of the store SPI. Implementations enlist on [TxContext.transaction] and must not * commit the transaction themselves. Each write assigns and returns the new version from the * store's commit order (a sequence, identity, or version column); the caller never supplies it. - * - * Writes are **not** suspending: they run on the caller's transaction, which may be a blocking jOOQ - * / JDBC transaction callback (`dsl.transaction { … }`). Dispatch the whole transaction to a - * blocking-friendly dispatcher (`withContext(Dispatchers.IO)`) at its boundary. */ interface CacheWriter { /** Writes [value] for [key] and returns the version the store assigned it. */ @@ -20,9 +16,4 @@ interface CacheWriter { /** Deletes [key] and returns the version the store assigned the removal. */ fun delete(key: K, tx: TxContext): Long - - /** Deletes many keys, returning the version assigned to each removal. */ - fun deleteAll(keys: Collection, tx: TxContext): Map = buildMap { - for (key in keys) put(key, delete(key, tx)) - } } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index 558cdc0..ea59b94 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -1,8 +1,12 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.github.benmanes.caffeine.cache.Cache +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch /** @@ -16,41 +20,22 @@ import kotlinx.coroutines.launch */ interface FlowStore { /** The live, delta-only stream of versioned mutations. */ - fun asFlow(): Flow> + fun asFlow(): SharedFlow> /** The latest value for [key], starting from a read-through load then following the stream. */ fun valueFlow(key: K): Flow /** - * The latest value for [key]: cache-first, reading through to the store on a miss. Not ordered - * against [asFlow] — a value just published as a delta may not be visible here yet. + * A suspending view of this store whose [AsyncFlowStore.get] dispatches the read-through itself. */ - suspend fun get(key: K): V? -} + val async: AsyncFlowStore -/** - * Read/write view of a store-backed map. Every mutation takes the caller's transaction handle [T] - * (a jOOQ `Configuration`, a JDBC `Connection`, …): the write is enlisted on it through - * [CacheWriter], which assigns the version, and the cache update and delta are published only when - * that transaction commits. - * - * The owner must **serialise writes to a given key** (single-writer-per-key): the version is the - * store's commit order, so unserialised concurrent writes to one key would settle on the wrong - * version. [V] must be an aggregate root — see [FlowStore]. - */ -interface MutableFlowStore : FlowStore { /** - * Reads [key]'s current value within [tx], always through the store and bypassing the cache, so - * it sees this transaction's own uncommitted writes and can take a locking read. Use for - * read-modify-write; use the cache-first [get] outside a transaction. + * The latest value for [key]: cache-first, reading through to the store on a miss. The + * read-through is blocking — call it within an IO context, as with the store's writes. Not + * ordered against [asFlow]: a value just published as a delta may not be visible here yet. */ - fun get(key: K, tx: T): V? - - fun put(key: K, value: V, tx: T) - - fun putAll(from: Map, tx: T) - - fun remove(key: K, tx: T) + fun get(key: K): V? } /** @@ -61,18 +46,21 @@ interface MutableFlowStore : FlowStore { fun flowStore( loader: CacheLoader, inbound: Flow>, - cache: FlowStoreCache, + cache: Cache>, scope: CoroutineScope, + dispatcher: CoroutineDispatcher = Dispatchers.IO, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, -): FlowStore = FlowStoreImpl(loader, cache, inbound, scope, bufferCapacity) +): FlowStore = + FlowStoreImpl(loader, FlowStoreCache(cache), inbound, scope, dispatcher, bufferCapacity) internal class FlowStoreImpl( loader: CacheLoader, cache: FlowStoreCache, inbound: Flow>, scope: CoroutineScope, + dispatcher: CoroutineDispatcher, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, -) : AbstractFlowStore(loader, cache, bufferCapacity) { +) : AbstractFlowStore(loader, cache, dispatcher, bufferCapacity) { init { scope.launch { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt index 0b3afb5..79b8439 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt @@ -1,96 +1,38 @@ package com.caplin.integration.datasourcex.util.store -import com.caplin.integration.datasourcex.util.cache.SuspendingCache import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent -import com.github.benmanes.caffeine.cache.AsyncCache -import com.github.benmanes.caffeine.cache.Caffeine -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.future.await -import kotlinx.coroutines.launch +import com.github.benmanes.caffeine.cache.Cache /** - * A [SuspendingCache] of versioned [CacheEntry] values that owns the store's version gating. The - * Caffeine map only ever holds completed entries; concurrent read-through misses for a key are - * coalesced through [inFlight] into a single load. + * A synchronous Caffeine [Cache] of versioned [CacheEntry] values that owns the store's version + * gating. A read-through miss loads single-flight under Caffeine's per-key compute; owner writes + * and inbound deltas apply through version-gated [putIfNewer] / [reflectIfNewer]. */ -class FlowStoreCache( - cache: AsyncCache>, - scope: CoroutineScope, -) : SuspendingCache>(cache, scope) { +internal class FlowStoreCache(private val cache: Cache>) { - private val inFlight = ConcurrentHashMap?>>() + fun getIfPresent(key: K): CacheEntry? = cache.getIfPresent(key) /** - * Returns the resident entry, or single-flight loads it via [load] on a miss and caches it, - * without regressing a fresher entry written meanwhile. A null load caches nothing. + * Cache-first; single-flight loads via [load] on a miss. A null load caches nothing. The atomic + * per-key compute coalesces concurrent misses and serialises loads against deltas for the key. */ - suspend fun loadIfNewer(key: K, load: suspend (K) -> Versioned?): CacheEntry? { - getIfPresent(key)?.let { - return it - } - val mine = CompletableFuture?>() - inFlight.putIfAbsent(key, mine)?.let { - return it.await() - } - val job = loaderScope.launch { - try { - val loaded = load(key) - mine.complete( - loaded?.let { putIfNewer(key, Live(it.value, it.version)) } ?: getIfPresent(key) - ) - } catch (t: Throwable) { - mine.completeExceptionally(t) - } finally { - inFlight.remove(key, mine) - } - } - job.invokeOnCompletion { throwable -> - if (throwable != null) { - mine.completeExceptionally(throwable) - } else if (!mine.isDone) { - mine.completeExceptionally(CancellationException("Job completed without completing future")) + fun getOrLoad(key: K, load: (K) -> Versioned?): CacheEntry? = + cache.asMap().compute(key) { _, existing -> + existing ?: load(key)?.let { Live(it.value, it.version) } } - } - return mine.await() - } - /** - * Atomically caches [candidate] unless a strictly-newer entry is already resident, returning the - * resident entry. The per-key atomic compute closes the check-then-put race. - */ - fun putIfNewer(key: K, candidate: CacheEntry): CacheEntry { - val merged = - asyncCache().asMap().merge(key, CompletableFuture.completedFuture(candidate)) { old, new -> - val current = old.getNow(null) - if (current != null && current.version >= candidate.version) old else new - } - return merged?.getNow(candidate) ?: candidate - } + /** Caches [candidate] unless a newer entry is already resident; returns the resident entry. */ + fun putIfNewer(key: K, candidate: CacheEntry): CacheEntry = + cache.asMap().merge(key, candidate) { old, new -> + if (old.version >= candidate.version) old else new + }!! /** - * Applies [event] to a resident or in-flight loading entry only, gated on version; other absent - * keys await the next read. + * Applies [event] to a resident entry only, gated on version; absent keys await the next read. */ fun reflectIfNewer(event: VersionedMapEvent) { - asyncCache().asMap().compute(event.key) { _, old -> - val current = old?.getNow(null) - if (current != null) { - if (event.version > current.version) CompletableFuture.completedFuture(event.toEntry()) else old - } else if (old != null) { - old - } else if (inFlight.containsKey(event.key)) { - CompletableFuture.completedFuture(event.toEntry()) - } else { - null - } + cache.asMap().computeIfPresent(event.key) { _, old -> + if (event.version > old.version) event.toEntry() else old } } } - -/** Builds a [FlowStoreCache] from a configured Caffeine builder. */ -fun Caffeine>.buildFlowStoreCache( - scope: CoroutineScope -): FlowStoreCache = FlowStoreCache(buildAsync(), scope) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index 219449b..7fb46ec 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -1,31 +1,70 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.github.benmanes.caffeine.cache.Cache +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * Read/write view of a store-backed map. Every mutation takes the caller's transaction handle [T]: + * the write is enlisted on it through [CacheWriter], which assigns the version, and the cache + * update and delta are published only when that transaction commits. + * + * The owner must **serialise writes to a given key** (single-writer-per-key): the version is the + * store's commit order, so unserialised concurrent writes to one key would settle on the wrong + * version. Serialise at the transaction layer — a locking read such as `SELECT … FOR UPDATE` via + * [CacheLoaderWriter.load], held across the transaction — not an in-process lock, which orders the + * calls but not the commits. [V] must be an aggregate root — see [FlowStore]. + */ +interface MutableFlowStore : FlowStore { + override val async: AsyncMutableFlowStore + + /** + * Reads [key]'s current value within [tx], always through the store and bypassing the cache, so + * it sees this transaction's own uncommitted writes and can take a locking read. Use for + * read-modify-write; use the cache-first [get] outside a transaction. + */ + fun get(key: K, tx: T): V? + + fun put(key: K, value: V, tx: T) + + fun putAll(from: Map, tx: T) + + fun remove(key: K, tx: T) +} /** * Creates a [MutableFlowStore] backed by [store] and the bounded hot set [cache]. * - * Each mutation takes the caller's transaction handle [T] (a jOOQ `Configuration`, a JDBC - * `Connection`, …), which [txContext] adapts into a [TxContext] so the write can enlist on it and - * register its publish. Mutations are non-suspending and run on that transaction; on commit the - * store refreshes its cache and `tryEmit`s the delta onto its stream. The stream's buffer is - * unbounded so the commit callback never suspends on backpressure; a permanently slow consumer - * therefore grows the buffer without bound (eventually OOM) rather than blocking the committer. - * [store] assigns each write's version. [V] must be an aggregate root — see [FlowStore]. + * Each mutation takes the caller's transaction handle [T], which [txContext] adapts into a + * [TxContext] so the write can enlist on it and register its publish. Mutations are non-suspending + * and run on that transaction; on commit the store refreshes its cache and `tryEmit`s the delta + * onto its stream. The stream's buffer is unbounded so the commit callback never suspends on + * backpressure; a permanently slow consumer therefore grows the buffer without bound (eventually + * OOM) rather than blocking the committer. [store] assigns each write's version; the blocking [get] + * read-through runs on the caller's thread, as the writes do. [V] must be an aggregate root — see + * [FlowStore]. */ fun mutableFlowStore( store: CacheLoaderWriter, - cache: FlowStoreCache, + cache: Cache>, + dispatcher: CoroutineDispatcher = Dispatchers.IO, txContext: (T) -> TxContext, -): MutableFlowStore = MutableFlowStoreImpl(store, cache, txContext) +): MutableFlowStore = + MutableFlowStoreImpl(store, FlowStoreCache(cache), dispatcher, txContext) internal class MutableFlowStoreImpl( private val writer: CacheLoaderWriter, cache: FlowStoreCache, + dispatcher: CoroutineDispatcher, private val txContext: (T) -> TxContext, // Unbounded buffer so the commit callback's tryEmit always succeeds without suspending: a slow // consumer grows the buffer, never blocks the committing thread. -) : AbstractFlowStore(writer, cache, Int.MAX_VALUE), MutableFlowStore { +) : AbstractFlowStore(writer, cache, dispatcher, Int.MAX_VALUE), MutableFlowStore { + + override val async: AsyncMutableFlowStore by lazy { + AsyncMutableFlowStoreImpl(this, dispatcher) + } override fun get(key: K, tx: T): V? = writer.load(key, txContext(tx))?.value diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt index 68af25a..727d2e2 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt @@ -4,12 +4,10 @@ import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean /** - * A driver-agnostic unit of work. [transaction] is the backend handle (jOOQ, JDBC, R2DBC, ...). The - * owner of the transaction begins and commits it; callers only register post-commit side-effects - * via [onCommitEnd] (and optional [onRollback] cleanup). - * - * Registered actions are **non-suspending** so they can run inside a synchronous commit/rollback - * callback (e.g. a jOOQ `TransactionListener`). + * A driver-agnostic unit of work. [transaction] is the underlying transaction handle. The owner of + * the transaction begins and commits it; callers only register post-commit side-effects via + * [onCommitEnd] (and optional [onRollback] cleanup), which run inside a synchronous commit/rollback + * callback. */ interface TxContext { val transaction: T diff --git a/util/src/samples/kotlin/samples/StoreSamples.kt b/util/src/samples/kotlin/samples/StoreSamples.kt index 095b2b3..84cb8fe 100644 --- a/util/src/samples/kotlin/samples/StoreSamples.kt +++ b/util/src/samples/kotlin/samples/StoreSamples.kt @@ -1,12 +1,11 @@ package samples +import com.caplin.integration.datasourcex.util.store.CacheEntry import com.caplin.integration.datasourcex.util.store.CacheLoaderWriter import com.caplin.integration.datasourcex.util.store.TxContext import com.caplin.integration.datasourcex.util.store.Versioned -import com.caplin.integration.datasourcex.util.store.buildFlowStoreCache import com.caplin.integration.datasourcex.util.store.mutableFlowStore import com.github.benmanes.caffeine.cache.Caffeine -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jooq.Configuration @@ -29,15 +28,13 @@ class StoreSamples { * * The transactional operations ([write], [delete] and the read-modify-write [load]) run on the * caller's transaction via `tx.transaction`. The cache-miss read-through [load] has no - * transaction, so it runs its blocking jOOQ call on [Dispatchers.IO]. + * transaction; the store runs its blocking jOOQ call on its IO dispatcher. */ class JooqAccountStore(private val dsl: DSLContext) : CacheLoaderWriter { - override suspend fun load(key: String): Versioned? = - withContext(Dispatchers.IO) { - dsl.fetchOne("select balance, version from account where id = ?", key)?.toVersioned(key) - } + override fun load(key: String): Versioned? = + dsl.fetchOne("select balance, version from account where id = ?", key)?.toVersioned(key) /** A locking read on the transaction's connection so a read-modify-write serialises writers. */ override fun load(key: String, tx: TxContext): Versioned? = @@ -82,9 +79,8 @@ class StoreSamples { * neither on rollback. [FlowStorePublishingListener], installed once on the [DSLContext], is what * turns each commit into the delta publish. */ - suspend fun jooqSample(rootDsl: DSLContext, scope: CoroutineScope) { - val cache = - Caffeine.newBuilder().maximumSize(10_000).buildFlowStoreCache(scope) + suspend fun jooqSample(rootDsl: DSLContext) { + val cache = Caffeine.newBuilder().maximumSize(10_000).build>() val store = mutableFlowStore(JooqAccountStore(rootDsl), cache, txContext = Configuration::asTxContext) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt deleted file mode 100644 index a6bf5b2..0000000 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/cache/SuspendingCacheTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.caplin.integration.datasourcex.util.cache - -import com.github.benmanes.caffeine.cache.Caffeine -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.shouldBe -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope - -// Real concurrency / real dispatchers, so these opt out of the project's coroutineTestScope. -class SuspendingCacheTest : - FunSpec({ - val scope = CoroutineScope(Dispatchers.Default) - afterSpec { scope.cancel() } - - test("concurrent gets for the same key share a single load").config( - coroutineTestScope = false - ) { - val calls = AtomicInteger(0) - val gate = CompletableDeferred() - val cache = - Caffeine.newBuilder().buildSuspending(scope) { key -> - calls.incrementAndGet() - gate.await() - "v:$key" - } - - coroutineScope { - val gets = List(20) { async { cache.get("k") } } - gate.complete(Unit) - gets.awaitAll().forEach { it shouldBe "v:k" } - } - - calls.get() shouldBe 1 - } - - test("eviction triggers a read-through reload").config(coroutineTestScope = false) { - val calls = ConcurrentHashMap() - val cache = - Caffeine.newBuilder().maximumSize(1).buildSuspending(scope) { key -> - calls.merge(key, 1, Int::plus) - "v:$key" - } - - cache.get("a") shouldBe "v:a" - cache.get("b") shouldBe "v:b" - cache.asyncCache().synchronous().cleanUp() - - cache.getIfPresent("a").shouldBeNull() - cache.get("a") shouldBe "v:a" - - calls["a"] shouldBe 2 - } - - test("a failed load surfaces to the caller without breaking the cache").config( - coroutineTestScope = false - ) { - val cache = - Caffeine.newBuilder().buildSuspending(scope) { key -> - if (key == "bad") error("boom") else "v:$key" - } - - shouldThrow { cache.get("bad") } - cache.get("good") shouldBe "v:good" - } - - test("put populates without invoking the loader").config(coroutineTestScope = false) { - val calls = AtomicInteger(0) - val cache = - Caffeine.newBuilder().buildSuspending(scope) { key -> - calls.incrementAndGet() - "loaded:$key" - } - - cache.put("k", "put") - cache.get("k") shouldBe "put" - - calls.get() shouldBe 0 - } - }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt index 500c4b6..3544491 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt @@ -2,54 +2,37 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Caffeine -import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope -// Real concurrency / real dispatchers, so these opt out of the project's coroutineTestScope. class FlowStoreCacheTest : FunSpec({ - val scope = CoroutineScope(Dispatchers.Default) - afterSpec { scope.cancel() } + fun newCache() = FlowStoreCache(Caffeine.newBuilder().build()) - test("concurrent reads on a miss share a single load").config(coroutineTestScope = false) { + test("getOrLoad caches the loaded value and serves it cache-first") { val calls = AtomicInteger(0) - val gate = CompletableDeferred() - val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) - val load: suspend (String) -> Versioned? = { key -> + val cache = newCache() + val load = { k: String -> calls.incrementAndGet() - gate.await() - Versioned("v:$key", 1L) - } - - coroutineScope { - val reads = List(20) { async { cache.loadIfNewer("k", load) } } - gate.complete(Unit) - reads.awaitAll().forEach { it shouldBe Live("v:k", 1L) } + Versioned("v:$k", 1L) } + cache.getOrLoad("k", load) shouldBe Live("v:k", 1L) + cache.getOrLoad("k", load) shouldBe Live("v:k", 1L) calls.get() shouldBe 1 } test("a load that finds nothing caches nothing") { - val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) + val cache = newCache() - cache.loadIfNewer("missing") { null }.shouldBeNull() + cache.getOrLoad("missing") { null }.shouldBeNull() cache.getIfPresent("missing").shouldBeNull() } test("putIfNewer keeps the strictly-newer resident entry") { - val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) + val cache = newCache() cache.putIfNewer("k", Live("v5", 5L)) shouldBe Live("v5", 5L) cache.putIfNewer("k", Live("v3", 3L)) shouldBe Live("v5", 5L) // older rejected @@ -57,45 +40,16 @@ class FlowStoreCacheTest : cache.putIfNewer("k", Live("v9", 9L)) shouldBe Live("v9", 9L) } - test("reflectIfNewer updates the cache during an in-flight load, preventing regression").config( - coroutineTestScope = false - ) { - val cache = Caffeine.newBuilder().buildFlowStoreCache(scope) - val loadGate = CompletableDeferred() - val resultGate = CompletableDeferred() - - val loadJob = async { - cache.loadIfNewer("k") { - loadGate.complete(Unit) - resultGate.await() - Versioned("v5", 5L) - } - } - - // Wait until the load is executing and in-flight - loadGate.await() - - // Call reflectIfNewer with a newer version (Version 6) while load is in-flight - cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v6", 6L)) + test("reflectIfNewer updates a resident entry only, version-gated") { + val cache = newCache() - // Complete the load (which yields Version 5) - resultGate.complete(Unit) - loadJob.await() + cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v1", 1L)) // absent -> no-op + cache.getIfPresent("k").shouldBeNull() - // Verify that the cache keeps the newer Version 6 and did not regress to Version 5 - cache.getIfPresent("k") shouldBe Live("v6", 6L) - } - - test("loadIfNewer throws CancellationException if the scope is cancelled").config( - coroutineTestScope = false - ) { - val deadScope = CoroutineScope(Dispatchers.Default) - deadScope.cancel() - - val cache = Caffeine.newBuilder().buildFlowStoreCache(deadScope) - - shouldThrow { - cache.loadIfNewer("k") { Versioned("v", 1L) } - } + cache.putIfNewer("k", Live("v5", 5L)) + cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v3", 3L)) // older -> ignored + cache.getIfPresent("k") shouldBe Live("v5", 5L) + cache.reflectIfNewer(VersionedMapEvent.Removed("k", 6L)) // newer -> tombstone + cache.getIfPresent("k") shouldBe Tombstone(6L) } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index 69c27f8..00eed5d 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -9,7 +9,6 @@ import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onSubscription @@ -23,7 +22,7 @@ class FlowStoreTest : flowStore( store, emptyFlow(), - Caffeine.newBuilder().buildFlowStoreCache(backgroundScope), + Caffeine.newBuilder().build(), backgroundScope, ) @@ -35,7 +34,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -59,7 +58,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v2", 2L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -77,7 +76,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -97,20 +96,17 @@ class FlowStoreTest : val owner = mutableFlowStore( store, - Caffeine.newBuilder().buildFlowStoreCache(backgroundScope), + Caffeine.newBuilder().build(), txContext = inMemoryTxContext, ) fun commit(action: (InMemoryTx) -> Unit) = InMemoryTx().also { action(it) }.commit() val attached = MutableStateFlow(false) - val ownerDeltas = - (owner.asFlow() as SharedFlow>).onSubscription { - attached.value = true - } + val ownerDeltas = owner.asFlow().onSubscription { attached.value = true } val consumer = flowStore( store, ownerDeltas, - Caffeine.newBuilder().buildFlowStoreCache(backgroundScope), + Caffeine.newBuilder().build(), backgroundScope, ) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt index 84429bf..7101313 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt @@ -1,9 +1,8 @@ package com.caplin.integration.datasourcex.util.store import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow /** * A trivial in-memory unit of work standing in for a database transaction: it buffers the store's @@ -42,6 +41,7 @@ val inMemoryTxContext: (InMemoryTx) -> TxContext = { handle -> class InMemoryCacheLoaderWriter : CacheLoaderWriter { private val backing = ConcurrentHashMap>() private val sequence = AtomicLong(0L) + val loadCount = AtomicInteger(0) /** Seeds a specific version directly, for tests that exercise version gating. */ fun seed(key: K, value: V, version: Long) { @@ -49,12 +49,13 @@ class InMemoryCacheLoaderWriter : CacheLoaderWriter? = backing[key] + override fun load(key: K): Versioned? { + loadCount.incrementAndGet() + return backing[key] + } override fun load(key: K, tx: TxContext): Versioned? = backing[key] - override fun loadAllKeys(): Flow = backing.keys.toList().asFlow() - override fun write(key: K, value: V, tx: TxContext): Long { val version = sequence.incrementAndGet() backing[key] = Versioned(value, version) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt index 4ff6f3f..7f1f04f 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt @@ -3,7 +3,6 @@ package com.caplin.integration.datasourcex.util.store import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe -import kotlinx.coroutines.flow.toList class InMemoryCacheLoaderWriterTest : FunSpec({ @@ -12,12 +11,7 @@ class InMemoryCacheLoaderWriterTest : val tx = inMemoryTxContext(InMemoryTx()) store.write("a", "A", tx) - store.write("b", "B", tx) - store.load("a") shouldBe Versioned("A", 1L) - store.loadAll(listOf("a", "b", "missing")) shouldBe - mapOf("a" to Versioned("A", 1L), "b" to Versioned("B", 2L)) - store.loadAllKeys().toList().toSet() shouldBe setOf("a", "b") store.delete("a", tx) store.load("a").shouldBeNull() diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt index dd6a572..e2edd00 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -5,18 +5,16 @@ import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Caffeine import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec -import io.kotest.engine.coroutines.backgroundScope import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk -import java.util.concurrent.CompletableFuture class MutableFlowStoreTest : FunSpec({ test("put publishes a versioned delta and updates the cache only on commit") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -33,7 +31,7 @@ class MutableFlowStoreTest : test("rollback publishes nothing and the next write gets a strictly greater version") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -52,7 +50,7 @@ class MutableFlowStoreTest : test("a writer failure propagates and leaves the cache and stream untouched") { val backing = mockk>() every { backing.write(any(), any(), any()) } throws RuntimeException("boom") - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -64,7 +62,7 @@ class MutableFlowStoreTest : test("get reflects the committed value only after commit") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) InMemoryTx().also { @@ -84,9 +82,9 @@ class MutableFlowStoreTest : test("get(key, tx) reads through the store within the transaction, bypassing the cache") { val backing = InMemoryCacheLoaderWriter() backing.seed("k", "fresh-in-db", 9L) - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() // A stale, lower-versioned entry sits in the cache. - cache.asyncCache().put("k", CompletableFuture.completedFuture(Live("stale-in-cache", 1L))) + cache.put("k", Live("stale-in-cache", 1L)) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.get("k") shouldBe "stale-in-cache" // cache-first @@ -95,7 +93,7 @@ class MutableFlowStoreTest : test("remove publishes a Removed delta and tombstones the cache") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -118,7 +116,7 @@ class MutableFlowStoreTest : test("putAll fails before commit when the writer omits a version, publishing nothing") { val backing = mockk>() every { backing.writeAll(any(), any()) } returns mapOf("a" to 1L) // "b" missing - val cache = Caffeine.newBuilder().buildFlowStoreCache(backgroundScope) + val cache = Caffeine.newBuilder().build>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -128,4 +126,37 @@ class MutableFlowStoreTest : expectNoEvents() } } + + test("async.get short-circuits a cache hit and reads through on a miss") { + val backing = InMemoryCacheLoaderWriter() + val cache = Caffeine.newBuilder().build>() + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + + InMemoryTx().also { + store.put("k", "v1", it) + it.commit() + } + + store.async.get("k") shouldBe "v1" + backing.loadCount.get() shouldBe 0 // served from cache, no read-through + + store.async.get("missing").shouldBeNull() + backing.loadCount.get() shouldBe 1 // miss dispatched one read-through + } + + test("async mutations write through and publish on commit") { + val backing = InMemoryCacheLoaderWriter() + val cache = Caffeine.newBuilder().build>() + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + + store.asFlow().test { + val tx = InMemoryTx() + store.async.put("k", "v1", tx) + expectNoEvents() + + tx.commit() + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v1", 1L) + store.async.get("k") shouldBe "v1" + } + } }) From 548d5c479388f384e0a563a86f1ffdace61efbde Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Wed, 3 Jun 2026 13:03:47 +0100 Subject: [PATCH 08/13] Update cache _then_ emit --- .../util/store/MutableFlowStore.kt | 9 +++--- .../util/store/MutableFlowStoreTest.kt | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index 7fb46ec..294df0d 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -94,12 +94,13 @@ internal class MutableFlowStoreImpl( } /** - * Emits [event] then updates the cache: the unbounded emit always accepts the delta, so the cache - * never sits ahead of an unpublished delta. Both steps are non-suspending and run inside the - * synchronous commit callback. + * Updates the cache then emits [event]: a [valueFlow] that subscribes concurrently with the + * commit either reads the already-updated cache in `onSubscription` or receives the delta, never + * losing the update to the gap between the two steps. Both are non-suspending and run inside the + * synchronous commit callback; the unbounded buffer means [signal] never rejects the delta. */ private fun publish(event: VersionedMapEvent) { - signal.tryEmit(event) cache.putIfNewer(event.key, event.toEntry()) + signal.tryEmit(event) } } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt index e2edd00..d524b9f 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -9,6 +9,10 @@ import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MutableFlowStoreTest : FunSpec({ @@ -29,6 +33,33 @@ class MutableFlowStoreTest : } } + test( + "publish updates the cache before emitting, so a delta observer never sees a stale cache" + ) { + val backing = InMemoryCacheLoaderWriter() + val cache = Caffeine.newBuilder().build>() + val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + + // An unconfined collector resumes synchronously inside the emitting tryEmit, so it captures + // the cache exactly as of the emit. The cache must already reflect the delta — otherwise a + // valueFlow subscribing in that window would seed from a stale entry and lose the update. + val cacheAtEmit = CompletableDeferred?>() + val collector = + CoroutineScope(Dispatchers.Unconfined).launch { + store.asFlow().collect { event -> + cacheAtEmit.complete(cache.getIfPresent(event.key)) + } + } + + InMemoryTx().also { + store.put("k", "v", it) + it.commit() + } + + cacheAtEmit.await() shouldBe Live("v", 1L) + collector.cancel() + } + test("rollback publishes nothing and the next write gets a strictly greater version") { val backing = InMemoryCacheLoaderWriter() val cache = Caffeine.newBuilder().build>() From 9180dc3e27a49d33d36dfbe07a38afce2e31a132 Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Thu, 4 Jun 2026 10:33:45 +0100 Subject: [PATCH 09/13] internal performance improvements --- .../util/store/AbstractFlowStore.kt | 3 +-- .../datasourcex/util/store/FlowStore.kt | 2 +- .../datasourcex/util/store/FlowStoreCache.kt | 13 ++++++++---- .../util/store/MutableFlowStore.kt | 2 +- .../samples/kotlin/samples/StoreSamples.kt | 2 +- .../datasourcex/util/store/FlowStoreTest.kt | 6 +++--- .../util/store/MutableFlowStoreTest.kt | 20 +++++++++---------- 7 files changed, 26 insertions(+), 22 deletions(-) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt index 6981721..0a79cff 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -40,8 +40,7 @@ internal abstract class AbstractFlowStore( // Blocking on a miss; the caller dispatches it, as with the transactional operations. override fun get(key: K): V? = readThrough(key)?.valueOrNull() - private fun readThrough(key: K): CacheEntry? = - cache.getIfPresent(key) ?: cache.getOrLoad(key, loader::load) + private fun readThrough(key: K): CacheEntry? = cache.getOrLoad(key, loader::load) // Suspending [get] for the [async] view: a cache hit returns inline; only a miss is dispatched. internal suspend fun getSuspending(key: K): V? { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index ea59b94..02bf2f6 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -46,7 +46,7 @@ interface FlowStore { fun flowStore( loader: CacheLoader, inbound: Flow>, - cache: Cache>, + cache: Cache?>, scope: CoroutineScope, dispatcher: CoroutineDispatcher = Dispatchers.IO, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt index 79b8439..ab13e28 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt @@ -2,13 +2,20 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.LoadingCache /** * A synchronous Caffeine [Cache] of versioned [CacheEntry] values that owns the store's version * gating. A read-through miss loads single-flight under Caffeine's per-key compute; owner writes * and inbound deltas apply through version-gated [putIfNewer] / [reflectIfNewer]. */ -internal class FlowStoreCache(private val cache: Cache>) { +internal class FlowStoreCache(private val cache: Cache?>) { + + init { + // The store owns read-through loading; a LoadingCache's loader and refreshAfterWrite would + // write entries that bypass version gating. + require(cache !is LoadingCache<*, *>) { "Pass a plain Cache, not a LoadingCache." } + } fun getIfPresent(key: K): CacheEntry? = cache.getIfPresent(key) @@ -17,9 +24,7 @@ internal class FlowStoreCache(private val cache: Cache Versioned?): CacheEntry? = - cache.asMap().compute(key) { _, existing -> - existing ?: load(key)?.let { Live(it.value, it.version) } - } + cache.get(key) { load(it)?.let { v -> Live(v.value, v.version) } } /** Caches [candidate] unless a newer entry is already resident; returns the resident entry. */ fun putIfNewer(key: K, candidate: CacheEntry): CacheEntry = diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index 294df0d..78fd9d5 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -47,7 +47,7 @@ interface MutableFlowStore : FlowStore { */ fun mutableFlowStore( store: CacheLoaderWriter, - cache: Cache>, + cache: Cache?>, dispatcher: CoroutineDispatcher = Dispatchers.IO, txContext: (T) -> TxContext, ): MutableFlowStore = diff --git a/util/src/samples/kotlin/samples/StoreSamples.kt b/util/src/samples/kotlin/samples/StoreSamples.kt index 84cb8fe..9cc119b 100644 --- a/util/src/samples/kotlin/samples/StoreSamples.kt +++ b/util/src/samples/kotlin/samples/StoreSamples.kt @@ -80,7 +80,7 @@ class StoreSamples { * turns each commit into the delta publish. */ suspend fun jooqSample(rootDsl: DSLContext) { - val cache = Caffeine.newBuilder().maximumSize(10_000).build>() + val cache = Caffeine.newBuilder().maximumSize(10_000).build?>() val store = mutableFlowStore(JooqAccountStore(rootDsl), cache, txContext = Configuration::asTxContext) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index 00eed5d..17130ed 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -34,7 +34,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -58,7 +58,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v2", 2L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { @@ -76,7 +76,7 @@ class FlowStoreTest : val store = InMemoryCacheLoaderWriter() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val consumer = flowStore(store, inbound, cache, backgroundScope) consumer.asFlow().test { diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt index d524b9f..0fa9518 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -18,7 +18,7 @@ class MutableFlowStoreTest : FunSpec({ test("put publishes a versioned delta and updates the cache only on commit") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -37,7 +37,7 @@ class MutableFlowStoreTest : "publish updates the cache before emitting, so a delta observer never sees a stale cache" ) { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) // An unconfined collector resumes synchronously inside the emitting tryEmit, so it captures @@ -62,7 +62,7 @@ class MutableFlowStoreTest : test("rollback publishes nothing and the next write gets a strictly greater version") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -81,7 +81,7 @@ class MutableFlowStoreTest : test("a writer failure propagates and leaves the cache and stream untouched") { val backing = mockk>() every { backing.write(any(), any(), any()) } throws RuntimeException("boom") - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -93,7 +93,7 @@ class MutableFlowStoreTest : test("get reflects the committed value only after commit") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) InMemoryTx().also { @@ -113,7 +113,7 @@ class MutableFlowStoreTest : test("get(key, tx) reads through the store within the transaction, bypassing the cache") { val backing = InMemoryCacheLoaderWriter() backing.seed("k", "fresh-in-db", 9L) - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() // A stale, lower-versioned entry sits in the cache. cache.put("k", Live("stale-in-cache", 1L)) val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) @@ -124,7 +124,7 @@ class MutableFlowStoreTest : test("remove publishes a Removed delta and tombstones the cache") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -147,7 +147,7 @@ class MutableFlowStoreTest : test("putAll fails before commit when the writer omits a version, publishing nothing") { val backing = mockk>() every { backing.writeAll(any(), any()) } returns mapOf("a" to 1L) // "b" missing - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { @@ -160,7 +160,7 @@ class MutableFlowStoreTest : test("async.get short-circuits a cache hit and reads through on a miss") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) InMemoryTx().also { @@ -177,7 +177,7 @@ class MutableFlowStoreTest : test("async mutations write through and publish on commit") { val backing = InMemoryCacheLoaderWriter() - val cache = Caffeine.newBuilder().build>() + val cache = Caffeine.newBuilder().build?>() val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) store.asFlow().test { From ebd5277a59322ed8b4e144e022674fe69eaeffbe Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Thu, 4 Jun 2026 11:40:20 +0100 Subject: [PATCH 10/13] Minimise the public API --- util/api/datasourcex-util.api | 88 ++++--------- .../util/store/AbstractFlowStore.kt | 2 +- .../datasourcex/util/store/CacheEntry.kt | 6 +- .../util/store/CacheLoaderWriter.kt | 15 --- .../datasourcex/util/store/FlowStore.kt | 48 +++++-- .../datasourcex/util/store/FlowStoreCache.kt | 10 ++ .../util/store/MutableFlowStore.kt | 24 ++-- .../datasourcex/util/store/Store.kt | 4 + .../store/{CacheLoader.kt => StoreReader.kt} | 2 +- .../store/{CacheWriter.kt => StoreWriter.kt} | 10 +- .../datasourcex/util/store/TxContext.kt | 50 -------- .../samples/kotlin/samples/StoreSamples.kt | 17 +-- .../util/store/FlowStoreCacheTest.kt | 7 + .../datasourcex/util/store/FlowStoreTest.kt | 81 +++++++----- ...yCacheLoaderWriter.kt => InMemoryStore.kt} | 2 +- ...aderWriterTest.kt => InMemoryStoreTest.kt} | 15 +-- .../util/store/MutableFlowStoreTest.kt | 121 +++++++++++++++--- .../datasourcex/util/store/TxContextTest.kt | 37 +----- 18 files changed, 285 insertions(+), 254 deletions(-) delete mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Store.kt rename util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/{CacheLoader.kt => StoreReader.kt} (75%) rename util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/{CacheWriter.kt => StoreWriter.kt} (64%) rename util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/{InMemoryCacheLoaderWriter.kt => InMemoryStore.kt} (96%) rename util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/{InMemoryCacheLoaderWriterTest.kt => InMemoryStoreTest.kt} (52%) diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index 0afe5e4..58c449c 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -564,42 +564,6 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/As public abstract fun remove (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public final class com/caplin/integration/datasourcex/util/store/AutoCommitTxContext : com/caplin/integration/datasourcex/util/store/TxContext { - public fun (Ljava/lang/Object;)V - public final fun commit ()V - public fun getTransaction ()Ljava/lang/Object; - public fun onCommitEnd (Lkotlin/jvm/functions/Function0;)V - public fun onRollback (Lkotlin/jvm/functions/Function0;)V - public final fun rollback ()V -} - -public abstract interface class com/caplin/integration/datasourcex/util/store/CacheEntry { - public abstract fun getVersion ()J -} - -public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoader { - public abstract fun load (Ljava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Versioned; -} - -public abstract interface class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter : com/caplin/integration/datasourcex/util/store/CacheLoader, com/caplin/integration/datasourcex/util/store/CacheWriter { - public fun load (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Lcom/caplin/integration/datasourcex/util/store/Versioned; -} - -public final class com/caplin/integration/datasourcex/util/store/CacheLoaderWriter$DefaultImpls { - public static fun load (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Lcom/caplin/integration/datasourcex/util/store/Versioned; - public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; -} - -public abstract interface class com/caplin/integration/datasourcex/util/store/CacheWriter { - public abstract fun delete (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J - public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J - public fun writeAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; -} - -public final class com/caplin/integration/datasourcex/util/store/CacheWriter$DefaultImpls { - public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/CacheWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; -} - public abstract interface class com/caplin/integration/datasourcex/util/store/FlowStore { public abstract fun asFlow ()Lkotlinx/coroutines/flow/SharedFlow; public abstract fun get (Ljava/lang/Object;)Ljava/lang/Object; @@ -608,21 +572,10 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Fl } public final class com/caplin/integration/datasourcex/util/store/FlowStoreKt { - public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; - public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; -} - -public final class com/caplin/integration/datasourcex/util/store/Live : com/caplin/integration/datasourcex/util/store/CacheEntry { - public fun (Ljava/lang/Object;J)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()J - public final fun copy (Ljava/lang/Object;J)Lcom/caplin/integration/datasourcex/util/store/Live; - public static synthetic fun copy$default (Lcom/caplin/integration/datasourcex/util/store/Live;Ljava/lang/Object;JILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Live; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun getVersion ()J - public fun hashCode ()I - public fun toString ()Ljava/lang/String; + public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/StoreReader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/StoreReader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static final fun flowStoreIn (Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/store/StoreReader;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; + public static synthetic fun flowStoreIn$default (Lkotlinx/coroutines/flow/Flow;Lcom/caplin/integration/datasourcex/util/store/StoreReader;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; } public abstract interface class com/caplin/integration/datasourcex/util/store/MutableFlowStore : com/caplin/integration/datasourcex/util/store/FlowStore { @@ -634,19 +587,30 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/Mu } public final class com/caplin/integration/datasourcex/util/store/MutableFlowStoreKt { - public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; - public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/CacheLoaderWriter;Lcom/github/benmanes/caffeine/cache/Cache;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static final fun mutableFlowStore (Lcom/caplin/integration/datasourcex/util/store/Store;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; + public static synthetic fun mutableFlowStore$default (Lcom/caplin/integration/datasourcex/util/store/Store;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/MutableFlowStore; } -public final class com/caplin/integration/datasourcex/util/store/Tombstone : com/caplin/integration/datasourcex/util/store/CacheEntry { - public fun (J)V - public final fun component1 ()J - public final fun copy (J)Lcom/caplin/integration/datasourcex/util/store/Tombstone; - public static synthetic fun copy$default (Lcom/caplin/integration/datasourcex/util/store/Tombstone;JILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Tombstone; - public fun equals (Ljava/lang/Object;)Z - public fun getVersion ()J - public fun hashCode ()I - public fun toString ()Ljava/lang/String; +public abstract interface class com/caplin/integration/datasourcex/util/store/Store : com/caplin/integration/datasourcex/util/store/StoreReader, com/caplin/integration/datasourcex/util/store/StoreWriter { +} + +public final class com/caplin/integration/datasourcex/util/store/Store$DefaultImpls { + public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/Store;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/StoreReader { + public abstract fun load (Ljava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/Versioned; +} + +public abstract interface class com/caplin/integration/datasourcex/util/store/StoreWriter { + public abstract fun delete (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J + public abstract fun load (Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Lcom/caplin/integration/datasourcex/util/store/Versioned; + public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lcom/caplin/integration/datasourcex/util/store/TxContext;)J + public fun writeAll (Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; +} + +public final class com/caplin/integration/datasourcex/util/store/StoreWriter$DefaultImpls { + public static fun writeAll (Lcom/caplin/integration/datasourcex/util/store/StoreWriter;Ljava/util/Map;Lcom/caplin/integration/datasourcex/util/store/TxContext;)Ljava/util/Map; } public abstract interface class com/caplin/integration/datasourcex/util/store/TxContext { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt index 0a79cff..3363ff1 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -20,7 +20,7 @@ internal const val DEFAULT_SIGNAL_BUFFER: Int = 256 * [dispatcher]. */ internal abstract class AbstractFlowStore( - protected val loader: CacheLoader, + protected val loader: StoreReader, protected val cache: FlowStoreCache, protected val dispatcher: CoroutineDispatcher, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt index 7322095..90fb15a 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheEntry.kt @@ -4,10 +4,10 @@ package com.caplin.integration.datasourcex.util.store * A versioned cache entry. [Live] holds a present value; [Tombstone] marks a removed key so a * stale, older read-through is rejected by version instead of silently repopulating the value. */ -sealed interface CacheEntry { +internal sealed interface CacheEntry { val version: Long } -data class Live(val value: V, override val version: Long) : CacheEntry +internal data class Live(val value: V, override val version: Long) : CacheEntry -data class Tombstone(override val version: Long) : CacheEntry +internal data class Tombstone(override val version: Long) : CacheEntry diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt deleted file mode 100644 index 7e442c8..0000000 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoaderWriter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.caplin.integration.datasourcex.util.store - -/** Combines [CacheLoader] and [CacheWriter] for a backend that implements both. */ -interface CacheLoaderWriter : CacheLoader, CacheWriter { - /** - * Reads [key]'s current persisted value within [tx] for read-modify-write, e.g. a locking `SELECT - * … FOR UPDATE` on the transaction's connection. Override it to support [MutableFlowStore.get] - * within a transaction (the default throws); the read should see the transaction's own - * uncommitted writes and serialise concurrent writers. - */ - fun load(key: K, tx: TxContext): Versioned? = - throw UnsupportedOperationException( - "Override load(key, tx) to support get(key, tx) / read-modify-write", - ) -} diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index 02bf2f6..8eeb0fc 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -1,7 +1,7 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent -import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -38,29 +38,61 @@ interface FlowStore { fun get(key: K): V? } +/** + * Operator form of [flowStore], reading this delta stream as the `inbound` source: `deltas + * .flowStoreIn(reader, caffeine, scope)`. Mirrors `shareIn`/`stateIn` — collection is launched in + * [scope] and the returned [FlowStore] is the read-only consumer. See [flowStore] for the + * lifecycle, caching, and [dispatcher] semantics. + */ +fun Flow>.flowStoreIn( + reader: StoreReader, + caffeine: Caffeine, + scope: CoroutineScope, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, +): FlowStore = + flowStore( + reader, + this, + caffeine, + scope, + dispatcher, + bufferCapacity, + ) + /** * Creates a read-only [FlowStore] consumer fed by an [inbound] delta stream (the owner's published - * mutations) and reading [loader] on a cache miss. The collection of [inbound] is launched in - * [scope]. [V] must be an aggregate root — see [FlowStore]. + * mutations) and reading [reader] on a cache miss. The collection of [inbound] is launched in + * [scope], so cancelling [scope] stops it and a failure of [inbound] surfaces through [scope] (its + * parent / exception handler), after which the store serves only stale reads. The hot set is a + * [Caffeine] cache built from [caffeine] (size it to bound memory). The blocking read-through load + * runs on [dispatcher] (IO by default). [V] must be an aggregate root — see [FlowStore]. */ fun flowStore( - loader: CacheLoader, + reader: StoreReader, inbound: Flow>, - cache: Cache?>, + caffeine: Caffeine, scope: CoroutineScope, dispatcher: CoroutineDispatcher = Dispatchers.IO, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, ): FlowStore = - FlowStoreImpl(loader, FlowStoreCache(cache), inbound, scope, dispatcher, bufferCapacity) + FlowStoreImpl( + reader, + caffeine.buildFlowStoreCache(), + inbound, + scope, + dispatcher, + bufferCapacity, + ) internal class FlowStoreImpl( - loader: CacheLoader, + reader: StoreReader, cache: FlowStoreCache, inbound: Flow>, scope: CoroutineScope, dispatcher: CoroutineDispatcher, bufferCapacity: Int = DEFAULT_SIGNAL_BUFFER, -) : AbstractFlowStore(loader, cache, dispatcher, bufferCapacity) { +) : AbstractFlowStore(reader, cache, dispatcher, bufferCapacity) { init { scope.launch { diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt index ab13e28..563de51 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt @@ -2,8 +2,18 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine import com.github.benmanes.caffeine.cache.LoadingCache +/** + * Builds a [FlowStoreCache] from a caller-supplied [Caffeine] spec. The entry type is internal, so + * the public factories take the untyped builder and stamp the value type here. The value is + * nullable so a read-through miss can cache nothing (Caffeine's compute skips a null result). + */ +@Suppress("UNCHECKED_CAST") +internal fun Caffeine.buildFlowStoreCache(): FlowStoreCache = + FlowStoreCache(build?>()) + /** * A synchronous Caffeine [Cache] of versioned [CacheEntry] values that owns the store's version * gating. A read-through miss loads single-flight under Caffeine's per-key compute; owner writes diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt index 78fd9d5..d848c66 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStore.kt @@ -1,20 +1,20 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent -import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers /** * Read/write view of a store-backed map. Every mutation takes the caller's transaction handle [T]: - * the write is enlisted on it through [CacheWriter], which assigns the version, and the cache + * the write is enlisted on it through [StoreWriter], which assigns the version, and the cache * update and delta are published only when that transaction commits. * * The owner must **serialise writes to a given key** (single-writer-per-key): the version is the * store's commit order, so unserialised concurrent writes to one key would settle on the wrong * version. Serialise at the transaction layer — a locking read such as `SELECT … FOR UPDATE` via - * [CacheLoaderWriter.load], held across the transaction — not an in-process lock, which orders the - * calls but not the commits. [V] must be an aggregate root — see [FlowStore]. + * [Store.load], held across the transaction — not an in-process lock, which orders the calls but + * not the commits. [V] must be an aggregate root — see [FlowStore]. */ interface MutableFlowStore : FlowStore { override val async: AsyncMutableFlowStore @@ -34,7 +34,8 @@ interface MutableFlowStore : FlowStore { } /** - * Creates a [MutableFlowStore] backed by [store] and the bounded hot set [cache]. + * Creates a [MutableFlowStore] backed by [store], with a bounded hot set built from [caffeine] + * (size it to bound memory). * * Each mutation takes the caller's transaction handle [T], which [txContext] adapts into a * [TxContext] so the write can enlist on it and register its publish. Mutations are non-suspending @@ -42,19 +43,20 @@ interface MutableFlowStore : FlowStore { * onto its stream. The stream's buffer is unbounded so the commit callback never suspends on * backpressure; a permanently slow consumer therefore grows the buffer without bound (eventually * OOM) rather than blocking the committer. [store] assigns each write's version; the blocking [get] - * read-through runs on the caller's thread, as the writes do. [V] must be an aggregate root — see - * [FlowStore]. + * read-through runs on the caller's thread, as the writes do. The suspending [async] operations and + * the [valueFlow] read-through instead run their blocking work on [dispatcher] (IO by default). [V] + * must be an aggregate root — see [FlowStore]. */ fun mutableFlowStore( - store: CacheLoaderWriter, - cache: Cache?>, + store: Store, + caffeine: Caffeine, dispatcher: CoroutineDispatcher = Dispatchers.IO, txContext: (T) -> TxContext, ): MutableFlowStore = - MutableFlowStoreImpl(store, FlowStoreCache(cache), dispatcher, txContext) + MutableFlowStoreImpl(store, caffeine.buildFlowStoreCache(), dispatcher, txContext) internal class MutableFlowStoreImpl( - private val writer: CacheLoaderWriter, + private val writer: Store, cache: FlowStoreCache, dispatcher: CoroutineDispatcher, private val txContext: (T) -> TxContext, diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Store.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Store.kt new file mode 100644 index 0000000..ac0583f --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/Store.kt @@ -0,0 +1,4 @@ +package com.caplin.integration.datasourcex.util.store + +/** Combines [StoreReader] and [StoreWriter] for a backend that implements both. */ +interface Store : StoreReader, StoreWriter diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/StoreReader.kt similarity index 75% rename from util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt rename to util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/StoreReader.kt index 5563780..47b74cf 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheLoader.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/StoreReader.kt @@ -1,6 +1,6 @@ package com.caplin.integration.datasourcex.util.store /** Read half of the store SPI. */ -interface CacheLoader { +interface StoreReader { fun load(key: K): Versioned? } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/StoreWriter.kt similarity index 64% rename from util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt rename to util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/StoreWriter.kt index 84d3dee..9af866e 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/CacheWriter.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/StoreWriter.kt @@ -5,7 +5,15 @@ package com.caplin.integration.datasourcex.util.store * commit the transaction themselves. Each write assigns and returns the new version from the * store's commit order (a sequence, identity, or version column); the caller never supplies it. */ -interface CacheWriter { +interface StoreWriter { + /** + * Reads [key]'s current persisted value within [tx] for read-modify-write, e.g. a locking `SELECT + * … FOR UPDATE` on the transaction's connection. Override it to support [MutableFlowStore.get] + * within a transaction (the default throws); the read should see the transaction's own + * uncommitted writes and serialise concurrent writers. + */ + fun load(key: K, tx: TxContext): Versioned? + /** Writes [value] for [key] and returns the version the store assigned it. */ fun write(key: K, value: V, tx: TxContext): Long diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt index 727d2e2..c70da54 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/TxContext.kt @@ -1,8 +1,5 @@ package com.caplin.integration.datasourcex.util.store -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicBoolean - /** * A driver-agnostic unit of work. [transaction] is the underlying transaction handle. The owner of * the transaction begins and commits it; callers only register post-commit side-effects via @@ -16,50 +13,3 @@ interface TxContext { fun onRollback(action: () -> Unit) {} } - -/** - * A [TxContext] that buffers registered actions and fires them when [commit] / [rollback] is - * invoked. Used for the non-transactional, auto-commit write path where the caller drives the - * single unit of work directly. - */ -class AutoCommitTxContext(override val transaction: T) : TxContext { - private val commitActions = CopyOnWriteArrayList<() -> Unit>() - private val rollbackActions = CopyOnWriteArrayList<() -> Unit>() - private val completed = AtomicBoolean(false) - - override fun onCommitEnd(action: () -> Unit) { - commitActions += action - } - - override fun onRollback(action: () -> Unit) { - rollbackActions += action - } - - /** Fires the registered commit actions once; reusing a completed context throws. */ - fun commit() { - check(completed.compareAndSet(false, true)) { "Transaction already completed" } - runAll(commitActions) - } - - /** - * Fires the registered rollback actions; a no-op once the context has committed or rolled back. - */ - fun rollback() { - if (!completed.compareAndSet(false, true)) return - runAll(rollbackActions) - } - - // Run every action even if some throw, so one failure cannot strand the rest; the first failure - // propagates with the others suppressed. - private fun runAll(actions: List<() -> Unit>) { - var failure: Throwable? = null - for (action in actions) { - try { - action() - } catch (t: Throwable) { - if (failure == null) failure = t else failure.addSuppressed(t) - } - } - failure?.let { throw it } - } -} diff --git a/util/src/samples/kotlin/samples/StoreSamples.kt b/util/src/samples/kotlin/samples/StoreSamples.kt index 9cc119b..06a7f30 100644 --- a/util/src/samples/kotlin/samples/StoreSamples.kt +++ b/util/src/samples/kotlin/samples/StoreSamples.kt @@ -1,7 +1,6 @@ package samples -import com.caplin.integration.datasourcex.util.store.CacheEntry -import com.caplin.integration.datasourcex.util.store.CacheLoaderWriter +import com.caplin.integration.datasourcex.util.store.Store import com.caplin.integration.datasourcex.util.store.TxContext import com.caplin.integration.datasourcex.util.store.Versioned import com.caplin.integration.datasourcex.util.store.mutableFlowStore @@ -23,15 +22,14 @@ class StoreSamples { data class Account(val id: String, val balance: Long) /** - * A [CacheLoaderWriter] over a jOOQ `account` table whose `version` is drawn from a database - * sequence, so the version is the store's durable commit order rather than an in-process counter. + * A [Store] over a jOOQ `account` table whose `version` is drawn from a database sequence, so the + * version is the store's durable commit order rather than an in-process counter. * * The transactional operations ([write], [delete] and the read-modify-write [load]) run on the * caller's transaction via `tx.transaction`. The cache-miss read-through [load] has no * transaction; the store runs its blocking jOOQ call on its IO dispatcher. */ - class JooqAccountStore(private val dsl: DSLContext) : - CacheLoaderWriter { + class JooqAccountStore(private val dsl: DSLContext) : Store { override fun load(key: String): Versioned? = dsl.fetchOne("select balance, version from account where id = ?", key)?.toVersioned(key) @@ -80,9 +78,12 @@ class StoreSamples { * turns each commit into the delta publish. */ suspend fun jooqSample(rootDsl: DSLContext) { - val cache = Caffeine.newBuilder().maximumSize(10_000).build?>() val store = - mutableFlowStore(JooqAccountStore(rootDsl), cache, txContext = Configuration::asTxContext) + mutableFlowStore( + JooqAccountStore(rootDsl), + Caffeine.newBuilder().maximumSize(10_000), + txContext = Configuration::asTxContext, + ) // Install the publishing listener once on the DSLContext; transactions opened from it run the // store's buffered commit/rollback actions in their commit/rollback callbacks. diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt index 3544491..936b22f 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt @@ -2,6 +2,7 @@ package com.caplin.integration.datasourcex.util.store import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent import com.github.benmanes.caffeine.cache.Caffeine +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe @@ -52,4 +53,10 @@ class FlowStoreCacheTest : cache.reflectIfNewer(VersionedMapEvent.Removed("k", 6L)) // newer -> tombstone cache.getIfPresent("k") shouldBe Tombstone(6L) } + + test("rejects a LoadingCache, whose own loader would bypass version gating") { + val loading = Caffeine.newBuilder().build?> { Live("v", 1L) } + + shouldThrow { FlowStoreCache(loading) } + } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index 17130ed..3ee933a 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -7,6 +7,7 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.engine.coroutines.backgroundScope import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow @@ -16,26 +17,19 @@ import kotlinx.coroutines.flow.onSubscription class FlowStoreTest : FunSpec({ test("get reads through to the store on a miss") { - val store = InMemoryCacheLoaderWriter() + val store = InMemoryStore() store.seed("k", "seeded", 1L) - val consumer = - flowStore( - store, - emptyFlow(), - Caffeine.newBuilder().build(), - backgroundScope, - ) + val consumer = flowStore(store, emptyFlow(), Caffeine.newBuilder(), backgroundScope) consumer.get("k") shouldBe "seeded" consumer.get("missing").shouldBeNull() } test("a delta older than a read-through load is dropped; a newer one is applied") { - val store = InMemoryCacheLoaderWriter() + val store = InMemoryStore() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().build?>() - val consumer = flowStore(store, inbound, cache, backgroundScope) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) consumer.asFlow().test { inbound.subscriptionCount.first { @@ -55,11 +49,10 @@ class FlowStoreTest : } test("an equal-version delta after a read-through is gated out") { - val store = InMemoryCacheLoaderWriter() + val store = InMemoryStore() store.seed("k", "v2", 2L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) - val cache = Caffeine.newBuilder().build?>() - val consumer = flowStore(store, inbound, cache, backgroundScope) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) consumer.asFlow().test { inbound.subscriptionCount.first { it >= 1 } @@ -73,11 +66,12 @@ class FlowStoreTest : } test("a removal tombstones a resident entry so a later read cannot resurrect it") { - val store = InMemoryCacheLoaderWriter() + val store = InMemoryStore() store.seed("k", "v5", 5L) val inbound = MutableSharedFlow>(extraBufferCapacity = 16) val cache = Caffeine.newBuilder().build?>() - val consumer = flowStore(store, inbound, cache, backgroundScope) + val consumer = + FlowStoreImpl(store, FlowStoreCache(cache), inbound, backgroundScope, Dispatchers.IO) consumer.asFlow().test { inbound.subscriptionCount.first { it >= 1 } @@ -92,23 +86,12 @@ class FlowStoreTest : } test("consumer converges to the owner's state through the delta stream") { - val store = InMemoryCacheLoaderWriter() - val owner = - mutableFlowStore( - store, - Caffeine.newBuilder().build(), - txContext = inMemoryTxContext, - ) + val store = InMemoryStore() + val owner = mutableFlowStore(store, Caffeine.newBuilder(), txContext = inMemoryTxContext) fun commit(action: (InMemoryTx) -> Unit) = InMemoryTx().also { action(it) }.commit() val attached = MutableStateFlow(false) val ownerDeltas = owner.asFlow().onSubscription { attached.value = true } - val consumer = - flowStore( - store, - ownerDeltas, - Caffeine.newBuilder().build(), - backgroundScope, - ) + val consumer = flowStore(store, ownerDeltas, Caffeine.newBuilder(), backgroundScope) consumer.valueFlow("k").test { attached.first { it } // the consumer's collector has attached to the owner's stream @@ -124,4 +107,42 @@ class FlowStoreTest : awaitItem().shouldBeNull() } } + + test("valueFlow ignores deltas for other keys and stale versions") { + val store = InMemoryStore() + store.seed("k", "v5", 5L) + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + + consumer.valueFlow("k").test { + awaitItem() shouldBe "v5" // seeded via read-through at version 5 + inbound.subscriptionCount.first { it >= 1 } + + inbound.emit(VersionedMapEvent.Upsert("other", "x", 9L)) // different key -> ignored + inbound.emit(VersionedMapEvent.Upsert("k", "stale", 3L)) // older version -> ignored + inbound.emit(VersionedMapEvent.Upsert("k", "v9", 9L)) // newer -> emitted + awaitItem() shouldBe "v9" + expectNoEvents() + } + } + + test("the consumer's async view reads through and follows the stream") { + val store = InMemoryStore() + store.seed("k", "seeded", 1L) + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + + consumer.async.get("k") shouldBe "seeded" // miss -> dispatched read-through + consumer.async.get("k") shouldBe "seeded" // hit -> served inline + consumer.async.get("missing").shouldBeNull() + + consumer.async.valueFlow("k").test { + awaitItem() shouldBe "seeded" + inbound.subscriptionCount.first { it >= 1 } + inbound.emit(VersionedMapEvent.Upsert("k", "v9", 9L)) + awaitItem() shouldBe "v9" + } + + consumer.async.asFlow().replayCache shouldBe emptyList() // delegates to the delta stream + } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryStore.kt similarity index 96% rename from util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt rename to util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryStore.kt index 7101313..c96cf26 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriter.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryStore.kt @@ -38,7 +38,7 @@ val inMemoryTxContext: (InMemoryTx) -> TxContext = { handle -> * backing [ConcurrentHashMap], versioned from a shared counter that stands in for a DB sequence; * the [TxContext] is ignored. */ -class InMemoryCacheLoaderWriter : CacheLoaderWriter { +class InMemoryStore : Store { private val backing = ConcurrentHashMap>() private val sequence = AtomicLong(0L) val loadCount = AtomicInteger(0) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryStoreTest.kt similarity index 52% rename from util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt rename to util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryStoreTest.kt index 7f1f04f..32e1998 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryCacheLoaderWriterTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/InMemoryStoreTest.kt @@ -4,10 +4,10 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe -class InMemoryCacheLoaderWriterTest : +class InMemoryStoreTest : FunSpec({ test("writes are loadable and deletes remove the entry") { - val store = InMemoryCacheLoaderWriter() + val store = InMemoryStore() val tx = inMemoryTxContext(InMemoryTx()) store.write("a", "A", tx) @@ -16,15 +16,4 @@ class InMemoryCacheLoaderWriterTest : store.delete("a", tx) store.load("a").shouldBeNull() } - - test("AutoCommitTxContext fires commit actions and skips rollback actions") { - val tx = AutoCommitTxContext(Unit) - val log = mutableListOf() - tx.onCommitEnd { log += "commit" } - tx.onRollback { log += "rollback" } - - tx.commit() - - log shouldBe listOf("commit") - } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt index 0fa9518..5c3455b 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/MutableFlowStoreTest.kt @@ -2,6 +2,7 @@ package com.caplin.integration.datasourcex.util.store import app.cash.turbine.test import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec @@ -14,12 +15,17 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +private fun newStore( + backing: Store, + cache: Cache?>, +) = MutableFlowStoreImpl(backing, FlowStoreCache(cache), Dispatchers.IO, inMemoryTxContext) + class MutableFlowStoreTest : FunSpec({ test("put publishes a versioned delta and updates the cache only on commit") { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) store.asFlow().test { val tx = InMemoryTx() @@ -36,9 +42,9 @@ class MutableFlowStoreTest : test( "publish updates the cache before emitting, so a delta observer never sees a stale cache" ) { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) // An unconfined collector resumes synchronously inside the emitting tryEmit, so it captures // the cache exactly as of the emit. The cache must already reflect the delta — otherwise a @@ -61,9 +67,9 @@ class MutableFlowStoreTest : } test("rollback publishes nothing and the next write gets a strictly greater version") { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) store.asFlow().test { val tx = InMemoryTx() @@ -79,10 +85,10 @@ class MutableFlowStoreTest : } test("a writer failure propagates and leaves the cache and stream untouched") { - val backing = mockk>() + val backing = mockk>() every { backing.write(any(), any(), any()) } throws RuntimeException("boom") val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) store.asFlow().test { shouldThrow { store.put("k", "v", InMemoryTx()) } @@ -92,9 +98,9 @@ class MutableFlowStoreTest : } test("get reflects the committed value only after commit") { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) InMemoryTx().also { store.put("k", "v1", it) @@ -111,21 +117,21 @@ class MutableFlowStoreTest : } test("get(key, tx) reads through the store within the transaction, bypassing the cache") { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() backing.seed("k", "fresh-in-db", 9L) val cache = Caffeine.newBuilder().build?>() // A stale, lower-versioned entry sits in the cache. cache.put("k", Live("stale-in-cache", 1L)) - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) store.get("k") shouldBe "stale-in-cache" // cache-first store.get("k", InMemoryTx()) shouldBe "fresh-in-db" // bypasses the cache, reads the store } test("remove publishes a Removed delta and tombstones the cache") { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) store.asFlow().test { InMemoryTx().also { @@ -144,11 +150,30 @@ class MutableFlowStoreTest : } } + test("putAll publishes a delta per entry on commit") { + val backing = InMemoryStore() + val cache = Caffeine.newBuilder().build?>() + val store = newStore(backing, cache) + + store.asFlow().test { + val tx = InMemoryTx() + store.putAll(mapOf("a" to "A", "b" to "B"), tx) + expectNoEvents() + + tx.commit() + setOf(awaitItem(), awaitItem()) shouldBe + setOf( + VersionedMapEvent.Upsert("a", "A", 1L), + VersionedMapEvent.Upsert("b", "B", 2L), + ) + } + } + test("putAll fails before commit when the writer omits a version, publishing nothing") { - val backing = mockk>() + val backing = mockk>() every { backing.writeAll(any(), any()) } returns mapOf("a" to 1L) // "b" missing val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) store.asFlow().test { val tx = InMemoryTx() @@ -159,9 +184,9 @@ class MutableFlowStoreTest : } test("async.get short-circuits a cache hit and reads through on a miss") { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) InMemoryTx().also { store.put("k", "v1", it) @@ -176,9 +201,9 @@ class MutableFlowStoreTest : } test("async mutations write through and publish on commit") { - val backing = InMemoryCacheLoaderWriter() + val backing = InMemoryStore() val cache = Caffeine.newBuilder().build?>() - val store = mutableFlowStore(backing, cache, txContext = inMemoryTxContext) + val store = newStore(backing, cache) store.asFlow().test { val tx = InMemoryTx() @@ -190,4 +215,60 @@ class MutableFlowStoreTest : store.async.get("k") shouldBe "v1" } } + + test("get(key, tx) returns null for an absent key") { + val backing = InMemoryStore() + val cache = Caffeine.newBuilder().build?>() + val store = newStore(backing, cache) + + store.get("missing", InMemoryTx()).shouldBeNull() + } + + test("async putAll and remove write through and publish on commit") { + val backing = InMemoryStore() + val cache = Caffeine.newBuilder().build?>() + val store = newStore(backing, cache) + + store.asFlow().test { + val tx = InMemoryTx() + store.async.putAll(mapOf("a" to "A", "b" to "B"), tx) + expectNoEvents() + tx.commit() + setOf(awaitItem(), awaitItem()) shouldBe + setOf( + VersionedMapEvent.Upsert("a", "A", 1L), + VersionedMapEvent.Upsert("b", "B", 2L), + ) + + val tx2 = InMemoryTx() + store.async.remove("a", tx2) + tx2.commit() + awaitItem() shouldBe VersionedMapEvent.Removed("a", 3L) + } + } + + test("async.get(key, tx) reads through within the transaction") { + val backing = InMemoryStore() + backing.seed("k", "fresh-in-db", 9L) + val cache = Caffeine.newBuilder().build?>() + val store = newStore(backing, cache) + + store.async.get("k", InMemoryTx()) shouldBe "fresh-in-db" + store.async.asFlow().replayCache shouldBe emptyList() // delegates to the delta stream + } + + test("async.valueFlow follows the mutable store") { + val backing = InMemoryStore() + val cache = Caffeine.newBuilder().build?>() + val store = newStore(backing, cache) + + store.async.valueFlow("k").test { + awaitItem().shouldBeNull() + InMemoryTx().also { + store.put("k", "v1", it) + it.commit() + } + awaitItem() shouldBe "v1" + } + } }) diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt index 9771d60..cbf23ac 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/TxContextTest.kt @@ -1,40 +1,17 @@ package com.caplin.integration.datasourcex.util.store -import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe class TxContextTest : FunSpec({ - test("commit fires actions once and rejects reuse") { - var commits = 0 - val tx = AutoCommitTxContext(Unit) - tx.onCommitEnd { commits++ } + test("onRollback defaults to a no-op") { + val ctx = + object : TxContext { + override val transaction = Unit - tx.commit() - commits shouldBe 1 - shouldThrow { tx.commit() } - commits shouldBe 1 - } - - test("rollback is a no-op once committed and does not fire rollback actions") { - var rollbacks = 0 - val tx = AutoCommitTxContext(Unit) - tx.onRollback { rollbacks++ } - - tx.commit() - tx.rollback() - rollbacks shouldBe 0 - } - - test("commit runs every action even if one throws, then rethrows") { - val log = mutableListOf() - val tx = AutoCommitTxContext(Unit) - tx.onCommitEnd { log += "a" } - tx.onCommitEnd { throw RuntimeException("boom") } - tx.onCommitEnd { log += "c" } + override fun onCommitEnd(action: () -> Unit) {} + } - shouldThrow { tx.commit() } - log shouldBe listOf("a", "c") + ctx.onRollback { error("must not run") } // default no-op: registers nothing, never fires } }) From 1fbd92c48020d3a81792c751b39a26d77f113557 Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Thu, 4 Jun 2026 14:25:01 +0100 Subject: [PATCH 11/13] Add SharedFlowCache and readme --- util/README.md | 62 +++++++- util/api/datasourcex-util.api | 13 ++ .../datasourcex/util/flow/FlowMapBenchmark.kt | 7 +- .../datasourcex/util/flow/FlowMap.kt | 17 ++- .../datasourcex/util/flow/SharedFlowCache.kt | 114 ++++++++++++++ .../datasourcex/util/flow/FlowMapTest.kt | 2 +- .../util/flow/SharedFlowCacheTest.kt | 144 ++++++++++++++++++ 7 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt create mode 100644 util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCacheTest.kt diff --git a/util/README.md b/util/README.md index d4a9272..1039eaa 100644 --- a/util/README.md +++ b/util/README.md @@ -3,9 +3,69 @@ Utility classes and functions commonly used in DataSource integrations, such as efficiently observable Maps, and stream transformations. +## Key components + +### Observable maps and shared flows + +| Component | Description | +| --- | --- | +| `FlowMap` / `MutableFlowMap` | A `Map` that is also observable via `asFlow`, `asFlowWithState`, and per-key `valueFlow`. Built with `mutableFlowMapOf` / `toMutableFlowMap`, or collected from an event stream with `flowMapIn`. | +| `CompletingSharedFlow` / `MutableCompletingSharedFlow` | A `SharedFlow` variant that also propagates completion and error events to subscribers. Built with `shareInCompleting`. | +| `SharedFlowCache` | Keyed cache of `SharedFlow`s, sharing one upstream collection per key and evicting a key once its upstream ends so the cache can't grow unbounded. Built with `sharedFlowCache(...)`, or `completingSharedFlowCache(...)` for a `CompletingSharedFlowCache` that propagates completion/errors. | +| `CompletingSharedFlowCache` / `LoadingCompletingSharedFlowCache` | Keyed cache of `CompletingSharedFlow`s, sharing one underlying collection per key. | + +### Stores + +| Component | Description | +| --- | --- | +| `FlowStore` / `MutableFlowStore` | A store-backed map exposing a delta-only stream plus a read-through, Caffeine-bounded cache. Built with `flowStore` / `flowStoreIn` / `mutableFlowStore`. | +| `AsyncFlowStore` / `AsyncMutableFlowStore` | Suspending views of the stores whose reads/writes dispatch the store I/O themselves. | +| `Store` / `StoreReader` / `StoreWriter` | The SPI you implement to back a `MutableFlowStore`; mutations enlist on the caller's transaction and publish on commit. | +| `TxContext` / `Versioned` | The transaction handle a write enlists on, and a value paired with the store-assigned version. | + +### Flow operators + +| Operator | Description | +| --- | --- | +| `bufferingDebounce` | Buffers elements until a quiet period elapses, then emits them as a `List`. | +| `throttleLatest` | Emits at most once per interval, keeping the latest value and dropping older ones. | +| `flatMapFirst` | Applies a function to the first element together with the entire upstream flow. | +| `demultiplexBy` | Groups elements by a key selector and processes each group's sub-flow. | +| `retryWithExponentialBackoff` | Retries the upstream on error with an exponential delay between attempts. | +| `cast` | Casts a `Flow<*>` to a `Flow`. | +| `timeoutFirst` / `timeoutFirstOrNull` / `timeoutFirstOrDefault` | Errors / emits null / emits a default if no first element arrives within a timeout. | +| `materialize` / `dematerialize` (and `*Unboxed`) | Convert between flow values and `ValueOrCompletion` events. | + +### Event models and folds + +| Component | Description | +| --- | --- | +| `MapEvent` / `SimpleMapEvent` / `SetEvent` / `VersionedMapEvent` | Sealed event types describing map and set mutations (with or without old values / versions). | +| `FlowMapStreamEvent` / `ValueOrCompletion` | Materialised stream events: initial-state-plus-deltas, and value-or-terminal-signal. | +| `runningFoldToMap*` / `runningFoldToSet` | Fold a delta stream into a live `Flow` of map / set snapshots. | +| `conflateKeys` / `toEvents` | Collapse per-key updates, and expand a collection stream into entry events. | + +### Serialization + +| Component | Description | +| --- | --- | +| `registerDataSourceSerializers` / `registerPersistentCollectionSerializers` | Register Fory serializers for the event types and persistent collections. | +| `DataSourceModule`, `registerDataSourceModule` / `addDataSourceModule` | Jackson 2 / Jackson 3 modules that serialize the event types without annotations. | +| `Jackson2JsonHandler` / `Jackson3JsonHandler` | `JsonHandler` implementations backed by a Jackson `ObjectMapper`. | + +### DataSource and general utilities + +| Component | Description | +| --- | --- | +| `AntPatternNamespace` | A `Namespace` matching subjects by Ant-style patterns, with path-variable extraction. | +| `SimpleDataSourceFactory` / `SimpleDataSourceConfig` | Build a `DataSource` from a simplified config for tests and examples. | +| `KLogger` | An slf4j `Logger` wrapper with lazily-evaluated message lambdas. | +| `ReadWriteLock` | A non-reentrant suspending read/write lock. | +| `withTimeout` | A coroutine timeout that throws `TimeoutException` rather than a `CancellationException`. | + @see com.caplin.integration.datasourcex.util.flow.FlowMap @see com.caplin.integration.datasourcex.util.store.MutableFlowStore Participating in an application-owned jOOQ transaction: -@sample samples.StoreSamples.jooqSample \ No newline at end of file +@sample samples.StoreSamples.jooqSample diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index 58c449c..ff551ba 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -185,9 +185,11 @@ public abstract interface class com/caplin/integration/datasourcex/util/flow/Flo } public final class com/caplin/integration/datasourcex/util/flow/FlowMapKt { + public static final fun flowMapIn (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun mutableFlowMapOf ()Lcom/caplin/integration/datasourcex/util/flow/MutableFlowMap; public static final fun mutableFlowMapOf ([Lkotlin/Pair;)Lcom/caplin/integration/datasourcex/util/flow/MutableFlowMap; public static final fun runningFoldToMapFlowMapStreamEvent (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; + public static final fun simpleFlowMapIn (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun simpleToFlowMapIn (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun toFlowMapIn (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun toMutableFlowMap (Ljava/util/Map;)Lcom/caplin/integration/datasourcex/util/flow/MutableFlowMap; @@ -371,6 +373,17 @@ public final class com/caplin/integration/datasourcex/util/flow/SetEventKt { public static final fun toEvents (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; } +public final class com/caplin/integration/datasourcex/util/flow/SharedFlowCache { + public final fun get (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/SharedFlow; +} + +public final class com/caplin/integration/datasourcex/util/flow/SharedFlowCacheKt { + public static final fun completingSharedFlowCache (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;I)Lcom/caplin/integration/datasourcex/util/flow/CompletingSharedFlowCache; + public static synthetic fun completingSharedFlowCache$default (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/flow/CompletingSharedFlowCache; + public static final fun sharedFlowCache (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;I)Lcom/caplin/integration/datasourcex/util/flow/SharedFlowCache; + public static synthetic fun sharedFlowCache$default (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/SharingStarted;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/flow/SharedFlowCache; +} + public abstract interface class com/caplin/integration/datasourcex/util/flow/SimpleMapEvent { } diff --git a/util/src/jmh/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapBenchmark.kt b/util/src/jmh/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapBenchmark.kt index e65d928..4714ce6 100644 --- a/util/src/jmh/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapBenchmark.kt +++ b/util/src/jmh/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapBenchmark.kt @@ -192,17 +192,16 @@ open class FlowMapBenchmark { } /** - * Measures the overhead of reconstructing a [FlowMap] from a stream of events using - * [toFlowMapIn]. + * Measures the overhead of reconstructing a [FlowMap] from a stream of events using [flowMapIn]. */ @Benchmark - fun toFlowMapInBenchmark() = runBlocking { + fun flowMapInBenchmark() = runBlocking { val events = flow { repeat(100) { emit(Upsert("key$it", null, it)) } emit(Populated) } val scope = CoroutineScope(Dispatchers.Default) - events.toFlowMapIn(scope) + events.flowMapIn(scope) scope.cancel() } diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMap.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMap.kt index c97d6eb..cdee18e 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMap.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMap.kt @@ -360,7 +360,7 @@ private class FlowMapImpl(initialMap: PersistentMap) : } } -suspend fun Flow>.toFlowMapIn( +suspend fun Flow>.flowMapIn( scope: CoroutineScope ): FlowMap { val flowMap = mutableFlowMapOf() @@ -377,8 +377,8 @@ suspend fun Flow>.toFlowMapIn( return flowMap } -@JvmName("simpleToFlowMapIn") -suspend fun Flow>.toFlowMapIn( +@JvmName("simpleFlowMapIn") +suspend fun Flow>.flowMapIn( scope: CoroutineScope ): FlowMap { val flowMap = mutableFlowMapOf() @@ -394,3 +394,14 @@ suspend fun Flow>.toFlowMapIn( populated.lock() return flowMap } + +@Deprecated("Renamed to flowMapIn", ReplaceWith("this.flowMapIn(scope)")) +suspend fun Flow>.toFlowMapIn( + scope: CoroutineScope +): FlowMap = flowMapIn(scope) + +@JvmName("simpleToFlowMapIn") +@Deprecated("Renamed to flowMapIn", ReplaceWith("this.flowMapIn(scope)")) +suspend fun Flow>.toFlowMapIn( + scope: CoroutineScope +): FlowMap = flowMapIn(scope) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt new file mode 100644 index 0000000..1dd32ee --- /dev/null +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt @@ -0,0 +1,114 @@ +package com.caplin.integration.datasourcex.util.flow + +import com.caplin.integration.datasourcex.util.flow.ValueOrCompletion.Completion +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.job + +/** + * A cache of [SharedFlow]s keyed by [K], sharing a single upstream collection per key across that + * key's subscribers. Create one with [sharedFlowCache], or [completingSharedFlowCache] for + * completion/error propagation. + * + * A key is created on first [get] and evicted once its upstream ends - it completes or errors, or + * (under a stopping `started` such as [SharingStarted.WhileSubscribed]) its subscribers have all + * gone - so the cache cannot grow without bound. Eviction tears down the key's sharing coroutine. + * + * This exposes a plain [SharedFlow], so subscribers see values only: the upstream's completion is + * not delivered to them, and neither is an unhandled upstream error - it fails the key's own + * sharing coroutine in isolation (the entry is evicted; the cache scope and other keys are + * unaffected) and reaches the scope's `CoroutineExceptionHandler`. Handle errors in the supplier + * (e.g. retry or catch). For completion/error propagation to subscribers, use + * [completingSharedFlowCache]. + * + * [get] resolves the entry lazily, so subscribe to the returned flow promptly: a flow retained + * across its key's eviction will not restart the upstream. + */ +class SharedFlowCache +internal constructor( + private val scope: CoroutineScope, + private val started: SharingStarted, + private val replay: Int, + private val evictWhen: ((V) -> Boolean)? = null, +) { + private val cache = ConcurrentHashMap>() + + /** The shared flow for [key], creating it from [supplier] on first access. */ + operator fun get(key: K, supplier: (K) -> Flow): SharedFlow = + cache.computeIfAbsent(key) { k -> share(k, supplier) } + + private fun share(key: K, supplier: (K) -> Flow): SharedFlow { + val entryScope = + CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job)) + val evictWhen = evictWhen + lateinit var shared: SharedFlow + val upstream = + if (evictWhen == null) supplier(key) + else + supplier(key).transformWhile { + // Evict before publishing a terminal value, so a re-get racing the completion misses + // and rebuilds rather than re-reading the terminal, then stop the upstream. + val terminal = evictWhen(it) + if (terminal) cache.remove(key, shared) + emit(it) + !terminal + } + shared = + upstream + // Drop the entry (if still ours) and tear the sharing coroutine down once the upstream + // ends: it completed/errored, or `started` cancelled it after the subscribers left. + .onCompletion { + cache.remove(key, shared) + entryScope.cancel() + } + .shareIn(entryScope, started, replay) + return shared + } +} + +/** + * Creates a [SharedFlowCache] - a keyed cache of plain [SharedFlow]s, one shared upstream per key, + * each evicted once its upstream ends (see [SharedFlowCache]). + * + * @param scope parent scope; each key's sharing coroutine is a child of it. + * @param started the [SharingStarted] strategy applied to each key's upstream. + * @param replay number of values replayed to new subscribers of a key. + */ +fun sharedFlowCache( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.WhileSubscribed(), + replay: Int = 0, +): SharedFlowCache = SharedFlowCache(scope, started, replay) + +/** + * Creates a [CompletingSharedFlowCache] backed by a [SharedFlowCache]: each supplier's terminal + * completion/error is materialised into the shared stream and rematerialised per subscriber, so - + * unlike a plain [SharedFlow] - completion and errors propagate downstream. The entry is evicted as + * the terminal is published, so a downstream `retry` re-resolves to a fresh entry (re-running the + * supplier) rather than re-reading the terminal. + * + * @param scope parent scope; each key's sharing coroutine is a child of it. + * @param started the [SharingStarted] strategy applied to each key's upstream. + * @param replay replayed values; use `>= 1` so a subscriber arriving after completion still + * observes the terminal. + */ +@OptIn(DelicateCoroutinesApi::class) +fun completingSharedFlowCache( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.WhileSubscribed(), + replay: Int = 0, +): CompletingSharedFlowCache { + val cache = SharedFlowCache(scope, started, replay) { it is Completion } + return CompletingSharedFlowCache { key, supplier -> + InternalCompletingSharedFlow { cache.get(key) { k -> supplier(k).materializeUnboxed() } } + } +} diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapTest.kt index 23c4ef2..cdbe14a 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/FlowMapTest.kt @@ -196,7 +196,7 @@ class FlowMapTest : val primaryMap = mutableFlowMapOf("1" to "A", "2" to "Ax") val filteredMap = - primaryMap.asFlow { _, value -> value.contains("x") }.toFlowMapIn(backgroundScope) + primaryMap.asFlow { _, value -> value.contains("x") }.flowMapIn(backgroundScope) filteredMap["1"] shouldBe null filteredMap["2"].shouldNotBeNull() shouldBeEqual "Ax" diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCacheTest.kt new file mode 100644 index 0000000..43d8350 --- /dev/null +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCacheTest.kt @@ -0,0 +1,144 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.caplin.integration.datasourcex.util.flow + +import app.cash.turbine.test +import com.caplin.integration.datasourcex.util.flow.ValueOrCompletion.Completion +import com.caplin.integration.datasourcex.util.flow.ValueOrCompletion.Value +import io.kotest.core.spec.style.FunSpec +import io.kotest.engine.coroutines.backgroundScope +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.matchers.types.shouldNotBeSameInstanceAs +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.launch + +class SharedFlowCacheTest : + FunSpec({ + test("shares a single upstream across subscribers") { + val collections = AtomicInteger(0) + val upstream = Channel(Channel.BUFFERED) + val cache = + sharedFlowCache(backgroundScope, SharingStarted.WhileSubscribed(), 1) + val supplier = { _: String -> + flow { + collections.incrementAndGet() + emitAll(upstream.receiveAsFlow()) + } + } + val got = mutableListOf() + + val shared = cache["K", supplier] + backgroundScope.launch { shared.collect { got.add("a:$it") } } + backgroundScope.launch { shared.collect { got.add("b:$it") } } + delay(100.milliseconds) + + upstream.send("X") + delay(100.milliseconds) + + got shouldContainExactlyInAnyOrder listOf("a:X", "b:X") + collections.get() shouldBeEqual 1 + } + + test("evicts a key once all subscribers leave") { + val upstream = Channel(Channel.BUFFERED) + val cache = + sharedFlowCache(backgroundScope, SharingStarted.WhileSubscribed(), 1) + val supplier = { _: String -> upstream.receiveAsFlow() } + + val first = cache["K", supplier] + val subscriber = backgroundScope.launch { first.collect {} } + delay(100.milliseconds) + + subscriber.cancelAndJoin() + delay(100.milliseconds) + + // The only subscriber has gone, so the key is evicted and a fresh get builds a new entry. + cache["K", supplier] shouldNotBeSameInstanceAs first + } + + test("an upstream error on one key does not affect other keys") { + val errors = mutableListOf() + val cacheScope = + CoroutineScope( + backgroundScope.coroutineContext + + CoroutineExceptionHandler { _, e -> errors.add(e) } + ) + val cache = sharedFlowCache(cacheScope, SharingStarted.WhileSubscribed(), 1) + val good = Channel(Channel.BUFFERED) + val gotGood = mutableListOf() + + backgroundScope.launch { + cache["good", { good.receiveAsFlow() }].collect { gotGood.add(it) } + } + backgroundScope.launch { cache["bad", { flow { error("boom") } }].collect {} } + delay(100.milliseconds) + + good.send("X") + delay(100.milliseconds) + + // The "bad" key's sharing coroutine failed in isolation; "good" keeps working. + gotGood shouldContainExactly listOf("X") + } + + test("completing adapter propagates completion") { + val upstream = Channel>(Channel.BUFFERED) + val cache: CompletingSharedFlowCache = + completingSharedFlowCache(backgroundScope, SharingStarted.WhileSubscribed(), 1) + + cache["A", { upstream.receiveAsFlow().dematerialize() }].test { + upstream.send(Value("X")) + awaitItem() shouldBeEqual "X" + upstream.send(Completion()) + awaitComplete() + } + } + + test("completing adapter propagates errors") { + val upstream = Channel>(Channel.BUFFERED) + val cache: CompletingSharedFlowCache = + completingSharedFlowCache(backgroundScope, SharingStarted.WhileSubscribed(), 1) + + cache["A", { upstream.receiveAsFlow().dematerialize() }].test { + upstream.send(Completion(IllegalArgumentException())) + awaitError().shouldBeInstanceOf() + } + } + + test("completing adapter supports downstream retry after an upstream error") { + val attempts = AtomicInteger(0) + val cache: CompletingSharedFlowCache = + completingSharedFlowCache(backgroundScope, SharingStarted.WhileSubscribed(), 1) + val supplier = { _: String -> + flow { + if (attempts.getAndIncrement() == 0) error("boom") + emit("ok") + } + } + + // The first attempt errors; the entry is evicted as the terminal is published, so retry + // re-resolves to a fresh entry, re-runs the supplier, and succeeds - no back-off needed. + cache["K", supplier] + .retry(3) { it is IllegalStateException } + .test { + awaitItem() shouldBeEqual "ok" + awaitComplete() + } + + attempts.get() shouldBeEqual 2 + } + }) From 26a412f1508f7611a47e779e293ba3858248c3d0 Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Thu, 4 Jun 2026 15:05:29 +0100 Subject: [PATCH 12/13] Add FlowStore.asFlow(query, predicate) snapshot-and-subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asFlow() is delta-only (replay = 0), so a subscriber never sees current state. The new overload runs a bulk query for the current matching entries, emits them, then follows the live stream — version-gated per key so the snapshot and the tail never conflict. The predicate scopes the live tail to the same logical set: a matching upsert enters/updates the view, one that stops matching leaves it as a Removed, and removals forward only for keys in the view (continuous query). The query runs blocking on the store's dispatcher and is not written to the cache. Defaults to always-true, so asFlow(query) follows the whole store. Additive public API. --- util/api/datasourcex-util.api | 12 ++ .../util/store/AbstractFlowStore.kt | 34 ++++++ .../datasourcex/util/store/AsyncFlowStore.kt | 15 +++ .../datasourcex/util/store/FlowStore.kt | 14 +++ .../samples/kotlin/samples/StoreSamples.kt | 28 +++++ .../datasourcex/util/store/FlowStoreTest.kt | 107 ++++++++++++++++++ 6 files changed, 210 insertions(+) diff --git a/util/api/datasourcex-util.api b/util/api/datasourcex-util.api index ff551ba..14874b8 100644 --- a/util/api/datasourcex-util.api +++ b/util/api/datasourcex-util.api @@ -566,10 +566,16 @@ public final class com/caplin/integration/datasourcex/util/serialization/jackson public abstract interface class com/caplin/integration/datasourcex/util/store/AsyncFlowStore { public abstract fun asFlow ()Lkotlinx/coroutines/flow/SharedFlow; + public abstract fun asFlow (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun asFlow$default (Lcom/caplin/integration/datasourcex/util/store/AsyncFlowStore;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; public abstract fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun valueFlow (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } +public final class com/caplin/integration/datasourcex/util/store/AsyncFlowStore$DefaultImpls { + public static synthetic fun asFlow$default (Lcom/caplin/integration/datasourcex/util/store/AsyncFlowStore;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + public abstract interface class com/caplin/integration/datasourcex/util/store/AsyncMutableFlowStore : com/caplin/integration/datasourcex/util/store/AsyncFlowStore { public abstract fun get (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -579,11 +585,17 @@ public abstract interface class com/caplin/integration/datasourcex/util/store/As public abstract interface class com/caplin/integration/datasourcex/util/store/FlowStore { public abstract fun asFlow ()Lkotlinx/coroutines/flow/SharedFlow; + public abstract fun asFlow (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun asFlow$default (Lcom/caplin/integration/datasourcex/util/store/FlowStore;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; public abstract fun get (Ljava/lang/Object;)Ljava/lang/Object; public abstract fun getAsync ()Lcom/caplin/integration/datasourcex/util/store/AsyncFlowStore; public abstract fun valueFlow (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } +public final class com/caplin/integration/datasourcex/util/store/FlowStore$DefaultImpls { + public static synthetic fun asFlow$default (Lcom/caplin/integration/datasourcex/util/store/FlowStore;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + public final class com/caplin/integration/datasourcex/util/store/FlowStoreKt { public static final fun flowStore (Lcom/caplin/integration/datasourcex/util/store/StoreReader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;I)Lcom/caplin/integration/datasourcex/util/store/FlowStore; public static synthetic fun flowStore$default (Lcom/caplin/integration/datasourcex/util/store/StoreReader;Lkotlinx/coroutines/flow/Flow;Lcom/github/benmanes/caffeine/cache/Caffeine;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/CoroutineDispatcher;IILjava/lang/Object;)Lcom/caplin/integration/datasourcex/util/store/FlowStore; diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt index 3363ff1..55948dc 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AbstractFlowStore.kt @@ -50,6 +50,40 @@ internal abstract class AbstractFlowStore( return withContext(dispatcher) { cache.getOrLoad(key, loader::load) }?.valueOrNull() } + override fun asFlow( + query: () -> Map>, + predicate: (K, V) -> Boolean, + ): Flow> = flow { + val highest = HashMap() // keys in view -> last version emitted + signal + .onSubscription { + val snapshot = withContext(dispatcher) { query() } + snapshot.forEach { (key, v) -> + highest[key] = v.version + this@flow.emit(VersionedMapEvent.Upsert(key, v.value, v.version)) + } + } + .collect { event -> + val baseline = highest[event.key] + if (baseline != null && event.version <= baseline) return@collect + when (event) { + is VersionedMapEvent.Upsert -> + if (predicate(event.key, event.value)) { + highest[event.key] = event.version + this@flow.emit(event) + } else if (baseline != null) { + highest.remove(event.key) + this@flow.emit(VersionedMapEvent.Removed(event.key, event.version)) + } + is VersionedMapEvent.Removed -> + if (baseline != null) { + highest.remove(event.key) + this@flow.emit(event) + } + } + } + } + override fun valueFlow(key: K): Flow = flow { var highest = Long.MIN_VALUE diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt index 97dd741..a2e0f37 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/AsyncFlowStore.kt @@ -13,6 +13,11 @@ import kotlinx.coroutines.withContext interface AsyncFlowStore { fun asFlow(): SharedFlow> + fun asFlow( + query: () -> Map>, + predicate: (K, V) -> Boolean = { _, _ -> true }, + ): Flow> + fun valueFlow(key: K): Flow suspend fun get(key: K): V? @@ -39,6 +44,11 @@ internal class AsyncFlowStoreImpl(private val store: AbstractF AsyncFlowStore { override fun asFlow(): SharedFlow> = store.asFlow() + override fun asFlow( + query: () -> Map>, + predicate: (K, V) -> Boolean, + ): Flow> = store.asFlow(query, predicate) + override fun valueFlow(key: K): Flow = store.valueFlow(key) override suspend fun get(key: K): V? = store.getSuspending(key) @@ -50,6 +60,11 @@ internal class AsyncMutableFlowStoreImpl( ) : AsyncMutableFlowStore { override fun asFlow(): SharedFlow> = store.asFlow() + override fun asFlow( + query: () -> Map>, + predicate: (K, V) -> Boolean, + ): Flow> = store.asFlow(query, predicate) + override fun valueFlow(key: K): Flow = store.valueFlow(key) override suspend fun get(key: K): V? = store.getSuspending(key) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt index 8eeb0fc..4f2891b 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStore.kt @@ -22,6 +22,20 @@ interface FlowStore { /** The live, delta-only stream of versioned mutations. */ fun asFlow(): SharedFlow> + /** + * A get-and-subscribe view: emits the entries [query] loads now (as [VersionedMapEvent.Upsert]), + * then follows the live stream, version-gated per key so the snapshot and the tail never + * conflict. [query] is the bulk current state (key to value+version); it runs blocking on the + * store's dispatcher and is not written to the cache. [predicate] scopes the live tail to the + * same logical set: a live upsert that matches enters or updates the view, one that stops + * matching leaves it as a [VersionedMapEvent.Removed], and removals forward only for keys in the + * view. [query] and [predicate] must agree (query = current rows matching predicate). + */ + fun asFlow( + query: () -> Map>, + predicate: (K, V) -> Boolean = { _, _ -> true }, + ): Flow> + /** The latest value for [key], starting from a read-through load then following the stream. */ fun valueFlow(key: K): Flow diff --git a/util/src/samples/kotlin/samples/StoreSamples.kt b/util/src/samples/kotlin/samples/StoreSamples.kt index 06a7f30..75a714d 100644 --- a/util/src/samples/kotlin/samples/StoreSamples.kt +++ b/util/src/samples/kotlin/samples/StoreSamples.kt @@ -1,11 +1,14 @@ package samples +import com.caplin.integration.datasourcex.util.flow.VersionedMapEvent +import com.caplin.integration.datasourcex.util.store.FlowStore import com.caplin.integration.datasourcex.util.store.Store import com.caplin.integration.datasourcex.util.store.TxContext import com.caplin.integration.datasourcex.util.store.Versioned import com.caplin.integration.datasourcex.util.store.mutableFlowStore import com.github.benmanes.caffeine.cache.Caffeine import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext import org.jooq.Configuration import org.jooq.DSLContext @@ -102,6 +105,31 @@ class StoreSamples { } } } + + /** + * Snapshot-and-subscribe over the same store: [FlowStore.asFlow] runs a bulk jOOQ query for the + * accounts currently in credit, emits them as the starting state, then follows the live delta + * stream. The predicate keeps the tail scoped to that set, so an account whose balance drops to + * zero leaves the view as a [VersionedMapEvent.Removed]. + */ + fun creditedAccounts( + store: FlowStore, + dsl: DSLContext, + ): Flow> = + store.asFlow( + query = { + dsl.fetch("select id, balance, version from account where balance > 0").associate { row + -> + val id = row.get("id", String::class.java) + id to + Versioned( + Account(id, row.get("balance", Long::class.java)), + row.get("version", Long::class.java), + ) + } + }, + predicate = { _, account -> account.balance > 0 }, + ) } private const val COMMIT_ACTIONS = "datasourcex.flowstore.commit-actions" diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index 3ee933a..e99e5c3 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -145,4 +145,111 @@ class FlowStoreTest : consumer.async.asFlow().replayCache shouldBe emptyList() // delegates to the delta stream } + + test("asFlow(query) emits the snapshot then follows newer deltas") { + val store = InMemoryStore() + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + val query = { mapOf("a" to Versioned("a1", 1L), "b" to Versioned("b2", 2L)) } + + consumer.asFlow(query).test { + setOf(awaitItem(), awaitItem()) shouldBe + setOf( + VersionedMapEvent.Upsert("a", "a1", 1L), + VersionedMapEvent.Upsert("b", "b2", 2L), + ) + inbound.subscriptionCount.first { it >= 1 } + + inbound.emit(VersionedMapEvent.Upsert("a", "a3", 3L)) + awaitItem() shouldBe VersionedMapEvent.Upsert("a", "a3", 3L) + } + } + + test("asFlow(query) gates a delta older-or-equal to the snapshot version") { + val store = InMemoryStore() + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + val query = { mapOf("k" to Versioned("v5", 5L)) } + + consumer.asFlow(query).test { + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v5", 5L) + inbound.subscriptionCount.first { it >= 1 } + + inbound.emit(VersionedMapEvent.Upsert("k", "stale", 5L)) // equal version -> gated + inbound.emit(VersionedMapEvent.Upsert("k", "v9", 9L)) // newer -> emitted + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v9", 9L) + expectNoEvents() + } + } + + test("asFlow(query) with no predicate follows the whole store: new keys and removals") { + val store = InMemoryStore() + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + val query = { emptyMap>() } + + consumer.asFlow(query).test { + inbound.subscriptionCount.first { it >= 1 } + + inbound.emit(VersionedMapEvent.Upsert("new", "x", 1L)) // new key enters the view + awaitItem() shouldBe VersionedMapEvent.Upsert("new", "x", 1L) + + inbound.emit(VersionedMapEvent.Removed("new", 2L)) // removal forwards + awaitItem() shouldBe VersionedMapEvent.Removed("new", 2L) + } + } + + test("asFlow(query, predicate) ignores a non-matching upsert for an untracked key") { + val store = InMemoryStore() + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + val query = { emptyMap>() } + val predicate = { _: String, v: String -> v.startsWith("keep") } + + consumer.asFlow(query, predicate).test { + inbound.subscriptionCount.first { it >= 1 } + + inbound.emit( + VersionedMapEvent.Upsert("a", "drop-me", 1L) + ) // untracked, no match -> ignored + inbound.emit(VersionedMapEvent.Upsert("b", "keep-me", 2L)) // matches -> enters + awaitItem() shouldBe VersionedMapEvent.Upsert("b", "keep-me", 2L) + expectNoEvents() + } + } + + test("asFlow(query, predicate) emits Removed when an in-view item stops matching") { + val store = InMemoryStore() + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + val query = { mapOf("k" to Versioned("keep-1", 1L)) } + val predicate = { _: String, v: String -> v.startsWith("keep") } + + consumer.asFlow(query, predicate).test { + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "keep-1", 1L) + inbound.subscriptionCount.first { it >= 1 } + + inbound.emit(VersionedMapEvent.Upsert("k", "gone-2", 2L)) // no longer matches -> leaves + awaitItem() shouldBe VersionedMapEvent.Removed("k", 2L) + expectNoEvents() + } + } + + test("asFlow(query, predicate) forwards a removal only for a key in the view") { + val store = InMemoryStore() + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + val query = { mapOf("k" to Versioned("keep-1", 1L)) } + val predicate = { _: String, v: String -> v.startsWith("keep") } + + consumer.asFlow(query, predicate).test { + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "keep-1", 1L) + inbound.subscriptionCount.first { it >= 1 } + + inbound.emit(VersionedMapEvent.Removed("other", 2L)) // untracked -> ignored + inbound.emit(VersionedMapEvent.Removed("k", 3L)) // in view -> forwarded + awaitItem() shouldBe VersionedMapEvent.Removed("k", 3L) + expectNoEvents() + } + } }) From 397a1b69a58000162b451f8294abb5ad98ecd923 Mon Sep 17 00:00:00 2001 From: Ross Anderson Date: Thu, 4 Jun 2026 16:09:46 +0100 Subject: [PATCH 13/13] Final review fixes --- .../datasourcex/util/flow/SharedFlowCache.kt | 8 ++++-- .../datasourcex/util/store/FlowStoreCache.kt | 10 ++++--- .../util/store/FlowStoreCacheTest.kt | 13 +++++----- .../datasourcex/util/store/FlowStoreTest.kt | 26 +++++++++++++++++++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt index 1dd32ee..99f55e2 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/flow/SharedFlowCache.kt @@ -96,10 +96,14 @@ fun sharedFlowCache( * the terminal is published, so a downstream `retry` re-resolves to a fresh entry (re-running the * supplier) rather than re-reading the terminal. * + * Each subscription re-resolves the entry, and the entry is evicted before its terminal is + * published, so a subscriber arriving after completion rebuilds (re-running the supplier) rather + * than attaching to the terminated stream - the terminal is never replayed to a late subscriber, so + * `replay` does not need to cover it. + * * @param scope parent scope; each key's sharing coroutine is a child of it. * @param started the [SharingStarted] strategy applied to each key's upstream. - * @param replay replayed values; use `>= 1` so a subscriber arriving after completion still - * observes the terminal. + * @param replay number of values replayed to new subscribers of a live (not-yet-completed) entry. */ @OptIn(DelicateCoroutinesApi::class) fun completingSharedFlowCache( diff --git a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt index 563de51..7024733 100644 --- a/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt +++ b/util/src/main/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCache.kt @@ -43,11 +43,13 @@ internal class FlowStoreCache(private val cache: Cache) { - cache.asMap().computeIfPresent(event.key) { _, old -> - if (event.version > old.version) event.toEntry() else old - } + putIfNewer(event.key, event.toEntry()) } } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt index 936b22f..3e578af 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreCacheTest.kt @@ -41,15 +41,16 @@ class FlowStoreCacheTest : cache.putIfNewer("k", Live("v9", 9L)) shouldBe Live("v9", 9L) } - test("reflectIfNewer updates a resident entry only, version-gated") { + test("reflectIfNewer seeds an absent key and updates a resident entry, version-gated") { val cache = newCache() - cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v1", 1L)) // absent -> no-op - cache.getIfPresent("k").shouldBeNull() + cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v1", 1L)) // absent -> seeded + cache.getIfPresent("k") shouldBe Live("v1", 1L) - cache.putIfNewer("k", Live("v5", 5L)) - cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v3", 3L)) // older -> ignored - cache.getIfPresent("k") shouldBe Live("v5", 5L) + cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "v3", 3L)) // newer -> applied + cache.getIfPresent("k") shouldBe Live("v3", 3L) + cache.reflectIfNewer(VersionedMapEvent.Upsert("k", "stale", 2L)) // older -> ignored + cache.getIfPresent("k") shouldBe Live("v3", 3L) cache.reflectIfNewer(VersionedMapEvent.Removed("k", 6L)) // newer -> tombstone cache.getIfPresent("k") shouldBe Tombstone(6L) } diff --git a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt index e99e5c3..08d409c 100644 --- a/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt +++ b/util/src/test/kotlin/com/caplin/integration/datasourcex/util/store/FlowStoreTest.kt @@ -48,6 +48,32 @@ class FlowStoreTest : } } + test( + "a delta for an absent key is reflected, so a later read does not regress to a stale value" + ) { + // The owner committed v9 and published the delta, but the read path (a lagging replica, + // modelled by leaving the backing store at v5) has not yet applied it. The consumer has + // already seen v9 on its stream, so it must serve v9 and never regress to the replica's v5. + val store = InMemoryStore() + store.seed("k", "v5", 5L) + val inbound = MutableSharedFlow>(extraBufferCapacity = 16) + val consumer = flowStore(store, inbound, Caffeine.newBuilder(), backgroundScope) + + consumer.asFlow().test { + inbound.subscriptionCount.first { it >= 1 } + + // v9 reaches the stream before any read-through has populated the cache. + inbound.emit(VersionedMapEvent.Upsert("k", "v9", 9L)) + awaitItem() shouldBe VersionedMapEvent.Upsert("k", "v9", 9L) + + // The delta must have been reflected even though the key was absent, so get serves v9 + // without falling back to the stale replica read. (Currently reflectIfNewer only updates + // resident keys, so the delta is dropped and get reads v5 — this assertion fails.) + consumer.get("k") shouldBe "v9" + store.loadCount.get() shouldBe 0 // the delta populated the cache; no read-through needed + } + } + test("an equal-version delta after a read-through is gated out") { val store = InMemoryStore() store.seed("k", "v2", 2L)