From e714680fe6c6ce1f3ac62d340cef0def1c4d86ee Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 10 Sep 2026 08:19:23 +0200 Subject: [PATCH 1/6] feat: expose resolve storage status Co-Authored-By: Codex --- Confidence/api/Confidence.api | 53 ++++++++++++++++ .../java/com/spotify/confidence/Confidence.kt | 39 ++++++++++-- .../confidence/ResolveStorageStatus.kt | 51 +++++++++++++++ .../confidence/cache/FileDiskStorage.kt | 44 ++++++++++--- .../cache/ResolveStorageMetadataStorage.kt | 22 +++++++ .../spotify/confidence/FileDiskStorageTest.kt | 47 +++++++++++++- .../confidence/ResolveStorageStatusTest.kt | 63 +++++++++++++++++++ .../confidence/StorageFileCacheTests.kt | 34 ++++++++++ README.md | 20 ++++++ 9 files changed, 359 insertions(+), 14 deletions(-) create mode 100644 Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt create mode 100644 Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt create mode 100644 Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt diff --git a/Confidence/api/Confidence.api b/Confidence/api/Confidence.api index ddf1a43e..9e96601d 100644 --- a/Confidence/api/Confidence.api +++ b/Confidence/api/Confidence.api @@ -8,6 +8,7 @@ public final class com/spotify/confidence/Confidence : com/spotify/confidence/Co public fun flush ()V public fun getContext ()Ljava/util/Map; public final fun getFlag (Ljava/lang/String;Ljava/lang/Object;)Lcom/spotify/confidence/Evaluation; + public final fun getStorageStatus (Lcom/spotify/confidence/ResolveStorageCheck;)Lcom/spotify/confidence/ResolveStorageStatus; public final fun getValue (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public final fun isStorageEmpty ()Z public fun putContext (Ljava/lang/String;Lcom/spotify/confidence/ConfidenceValue;)V @@ -472,6 +473,12 @@ public final class com/spotify/confidence/LoggingLevel : java/lang/Enum { public static fun values ()[Lcom/spotify/confidence/LoggingLevel; } +public final class com/spotify/confidence/MaxAgeStorageCheck : com/spotify/confidence/ResolveStorageCheck { + public fun (J)V + public fun check (Lcom/spotify/confidence/ResolveStorageMetadata;)Lcom/spotify/confidence/ResolveStorageStatus; + public final fun getMaxAgeMillis ()J +} + public abstract interface class com/spotify/confidence/Producer { public abstract fun stop ()V public abstract fun updates ()Lkotlinx/coroutines/flow/Flow; @@ -497,6 +504,52 @@ public final class com/spotify/confidence/ResolveReason : java/lang/Enum { public static fun values ()[Lcom/spotify/confidence/ResolveReason; } +public abstract interface class com/spotify/confidence/ResolveStorageCheck { + public abstract fun check (Lcom/spotify/confidence/ResolveStorageMetadata;)Lcom/spotify/confidence/ResolveStorageStatus; +} + +public final class com/spotify/confidence/ResolveStorageMetadata { + public fun (ZLjava/util/Date;)V + public final fun component1 ()Z + public final fun component2 ()Ljava/util/Date; + public final fun copy (ZLjava/util/Date;)Lcom/spotify/confidence/ResolveStorageMetadata; + public static synthetic fun copy$default (Lcom/spotify/confidence/ResolveStorageMetadata;ZLjava/util/Date;ILjava/lang/Object;)Lcom/spotify/confidence/ResolveStorageMetadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getLastFetchedAt ()Ljava/util/Date; + public fun hashCode ()I + public final fun isEmpty ()Z + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/spotify/confidence/ResolveStorageStatus { +} + +public final class com/spotify/confidence/ResolveStorageStatus$Empty : com/spotify/confidence/ResolveStorageStatus { + public static final field INSTANCE Lcom/spotify/confidence/ResolveStorageStatus$Empty; +} + +public final class com/spotify/confidence/ResolveStorageStatus$Fresh : com/spotify/confidence/ResolveStorageStatus { + public fun (Ljava/util/Date;)V + public final fun component1 ()Ljava/util/Date; + public final fun copy (Ljava/util/Date;)Lcom/spotify/confidence/ResolveStorageStatus$Fresh; + public static synthetic fun copy$default (Lcom/spotify/confidence/ResolveStorageStatus$Fresh;Ljava/util/Date;ILjava/lang/Object;)Lcom/spotify/confidence/ResolveStorageStatus$Fresh; + public fun equals (Ljava/lang/Object;)Z + public final fun getLastFetchedAt ()Ljava/util/Date; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/spotify/confidence/ResolveStorageStatus$Stale : com/spotify/confidence/ResolveStorageStatus { + public fun (Ljava/util/Date;)V + public final fun component1 ()Ljava/util/Date; + public final fun copy (Ljava/util/Date;)Lcom/spotify/confidence/ResolveStorageStatus$Stale; + public static synthetic fun copy$default (Lcom/spotify/confidence/ResolveStorageStatus$Stale;Ljava/util/Date;ILjava/lang/Object;)Lcom/spotify/confidence/ResolveStorageStatus$Stale; + public fun equals (Ljava/lang/Object;)Z + public final fun getLastFetchedAt ()Ljava/util/Date; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract class com/spotify/confidence/Result { } diff --git a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt index 92b2847c..440586fe 100644 --- a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt +++ b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt @@ -6,6 +6,8 @@ import com.spotify.confidence.ConfidenceError.ParseError import com.spotify.confidence.apply.FlagApplierWithRetries import com.spotify.confidence.cache.DiskStorage import com.spotify.confidence.cache.FileDiskStorage +import com.spotify.confidence.cache.ResolveStorageMetadataStorage +import com.spotify.confidence.cache.getResolveStorageMetadata import com.spotify.confidence.client.FlagApplierClient import com.spotify.confidence.client.FlagApplierClientImpl import com.spotify.confidence.client.SdkMetadata @@ -59,12 +61,15 @@ class Confidence internal constructor( diskStorage = diskStorage ) - private suspend fun resolve(flags: List): Result { + private suspend fun resolve( + flags: List, + resolveContext: Map + ): Result { debugLogger?.let { - debugLogger.logFlag("Resolve", "${getContext()}") + debugLogger.logFlag("Resolve", "$resolveContext") } - return flagResolver.resolve(flags, getContext()).also { - debugLogger?.logFlag("Resolve Completed", "${getContext()}") + return flagResolver.resolve(flags, resolveContext).also { + debugLogger?.logFlag("Resolve Completed", "$resolveContext") } } @@ -171,6 +176,12 @@ class Confidence internal constructor( */ fun isStorageEmpty(): Boolean = diskStorage.read() == FlagResolution.EMPTY + /** + * Returns the status of the cached flag resolution according to [check]. + */ + fun getStorageStatus(check: ResolveStorageCheck): ResolveStorageStatus = + check.check(diskStorage.getResolveStorageMetadata()) + /** * Mutate context by adding an entry and removing another * @param context context to add. @@ -327,12 +338,19 @@ class Confidence internal constructor( private suspend fun fetchAndStore(failOnStaleResponse: Boolean = false): Result { try { - return when (val resolveResponse = resolve(listOf())) { + // Empty/not-modified responses do not carry their context, so retain the context used for the request. + val resolveContext = getContext() + return when (val resolveResponse = resolve(listOf(), resolveContext)) { is Result.Success -> { val staleResponse = resolveResponse.data != FlagResolution.EMPTY && resolveResponse.data.context != getContext() when { - resolveResponse.data == FlagResolution.EMPTY -> Result.Success(Unit) + resolveResponse.data == FlagResolution.EMPTY -> { + if (resolveContext == getContext()) { + markStorageFetched() + } + Result.Success(Unit) + } staleResponse -> { val message = "Discarding stale resolve response: " + "context changed during in-flight request" @@ -345,6 +363,7 @@ class Confidence internal constructor( } else -> { diskStorage.store(resolveResponse.data) + markStorageFetched() Result.Success(Unit) } } @@ -362,6 +381,14 @@ class Confidence internal constructor( } } + private fun markStorageFetched() { + try { + (diskStorage as? ResolveStorageMetadataStorage)?.markFetched() + } catch (e: Exception) { + debugLogger?.logMessage("Failed to store resolve timestamp", isWarning = true, throwable = e) + } + } + private fun fetch(): Job = coroutineScope.launch(networkExceptionHandler) { when (val result = fetchAndStore()) { is Result.Success -> Unit diff --git a/Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt b/Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt new file mode 100644 index 00000000..0dfcba23 --- /dev/null +++ b/Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt @@ -0,0 +1,51 @@ +package com.spotify.confidence + +import com.spotify.confidence.client.Clock +import java.util.Date + +/** The status of the cached flag resolution on disk. */ +sealed interface ResolveStorageStatus { + /** No cached flag resolution is available. */ + object Empty : ResolveStorageStatus + + /** The cached flag resolution is stale according to the supplied [ResolveStorageCheck]. */ + data class Stale(val lastFetchedAt: Date?) : ResolveStorageStatus + + /** The cached flag resolution is fresh according to the supplied [ResolveStorageCheck]. */ + data class Fresh(val lastFetchedAt: Date?) : ResolveStorageStatus +} + +/** Determines resolve storage status from its metadata. */ +fun interface ResolveStorageCheck { + /** Returns the storage status according to [metadata]. */ + fun check(metadata: ResolveStorageMetadata): ResolveStorageStatus +} + +/** Metadata available when checking resolve storage. */ +data class ResolveStorageMetadata( + val isEmpty: Boolean, + val lastFetchedAt: Date? +) + +/** Considers resolve storage stale when its fetch time is unknown or at least [maxAgeMillis] old. */ +class MaxAgeStorageCheck internal constructor( + val maxAgeMillis: Long, + private val clock: Clock +) : ResolveStorageCheck { + constructor(maxAgeMillis: Long) : this(maxAgeMillis, Clock.CalendarBacked.systemUTC()) + + init { + require(maxAgeMillis > 0) { "maxAgeMillis must be positive" } + } + + override fun check(metadata: ResolveStorageMetadata): ResolveStorageStatus { + if (metadata.isEmpty) return ResolveStorageStatus.Empty + val fetchedAt = metadata.lastFetchedAt?.let { Date(it.time) } + val isStale = fetchedAt == null || clock.currentTime().time - fetchedAt.time >= maxAgeMillis + return if (isStale) { + ResolveStorageStatus.Stale(fetchedAt) + } else { + ResolveStorageStatus.Fresh(fetchedAt) + } + } +} diff --git a/Confidence/src/main/java/com/spotify/confidence/cache/FileDiskStorage.kt b/Confidence/src/main/java/com/spotify/confidence/cache/FileDiskStorage.kt index 1b4881f6..ebdadc86 100644 --- a/Confidence/src/main/java/com/spotify/confidence/cache/FileDiskStorage.kt +++ b/Confidence/src/main/java/com/spotify/confidence/cache/FileDiskStorage.kt @@ -3,21 +3,27 @@ package com.spotify.confidence.cache import android.content.Context import com.spotify.confidence.FlagResolution import com.spotify.confidence.apply.ApplyInstance +import com.spotify.confidence.client.Clock import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File +import java.util.Date import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.read import kotlin.concurrent.write internal const val FLAGS_FILE_NAME = "confidence_flags_cache.json" internal const val APPLY_FILE_NAME = "confidence_apply_cache.json" +internal const val RESOLVE_METADATA_FILE_NAME = "confidence_flags_cache_metadata.json" internal class FileDiskStorage internal constructor( private val flagsFile: File, - private val applyFile: File -) : DiskStorage { + private val applyFile: File, + private val resolveMetadataFile: File, + private val clock: Clock = Clock.CalendarBacked.systemUTC() +) : DiskStorage, ResolveStorageMetadataStorage { private val lock = ReentrantReadWriteLock() + private val metadataJson = Json { ignoreUnknownKeys = true } override fun store(flagResolution: FlagResolution) { write(Json.encodeToString(flagResolution)) @@ -26,9 +32,35 @@ internal class FileDiskStorage internal constructor( override fun clear() { lock.write { flagsFile.delete() + resolveMetadataFile.delete() } } + override fun markFetched() = lock.write { + writeResolveMetadata() + } + + private fun writeResolveMetadata() { + resolveMetadataFile.writeText( + metadataJson.encodeToString( + StoredResolveStorageMetadata(lastFetchedAtMillis = clock.currentTime().time) + ) + ) + } + + override fun getLastFetchedAt(): Date? = lock.read { + if (!resolveMetadataFile.exists()) return null + runCatching { + metadataJson.decodeFromString(resolveMetadataFile.readText()) + .lastFetchedAtMillis + .let(::Date) + }.getOrNull() + } + + private fun write(data: String) = lock.write { + flagsFile.writeText(data) + } + override fun writeApplyData(applyData: Map>) { applyFile.writeText(Json.encodeToString(applyData)) } @@ -48,10 +80,6 @@ internal class FileDiskStorage internal constructor( } } - private fun write(data: String) = lock.write { - flagsFile.writeText(data) - } - override fun read(): FlagResolution = lock.read { if (!flagsFile.exists()) return FlagResolution.EMPTY val fileText: String = flagsFile.bufferedReader().use { it.readText() } @@ -64,6 +92,7 @@ internal class FileDiskStorage internal constructor( // Delete corrupted file - safe to do while holding read lock // since we're the only thread accessing it flagsFile.delete() + resolveMetadataFile.delete() FlagResolution.EMPTY } } @@ -73,7 +102,8 @@ internal class FileDiskStorage internal constructor( fun create(context: Context): DiskStorage { return FileDiskStorage( flagsFile = File(context.filesDir, FLAGS_FILE_NAME), - applyFile = File(context.filesDir, APPLY_FILE_NAME) + applyFile = File(context.filesDir, APPLY_FILE_NAME), + resolveMetadataFile = File(context.filesDir, RESOLVE_METADATA_FILE_NAME) ) } } diff --git a/Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt b/Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt new file mode 100644 index 00000000..acd52808 --- /dev/null +++ b/Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt @@ -0,0 +1,22 @@ +package com.spotify.confidence.cache + +import com.spotify.confidence.FlagResolution +import com.spotify.confidence.ResolveStorageMetadata +import kotlinx.serialization.Serializable +import java.util.Date + +@Serializable +internal data class StoredResolveStorageMetadata( + val lastFetchedAtMillis: Long +) + +internal interface ResolveStorageMetadataStorage { + fun markFetched() + + fun getLastFetchedAt(): Date? +} + +internal fun DiskStorage.getResolveStorageMetadata(): ResolveStorageMetadata = ResolveStorageMetadata( + isEmpty = read() == FlagResolution.EMPTY, + lastFetchedAt = (this as? ResolveStorageMetadataStorage)?.getLastFetchedAt()?.let { Date(it.time) } +) diff --git a/Confidence/src/test/java/com/spotify/confidence/FileDiskStorageTest.kt b/Confidence/src/test/java/com/spotify/confidence/FileDiskStorageTest.kt index 7a957e99..a0833e39 100644 --- a/Confidence/src/test/java/com/spotify/confidence/FileDiskStorageTest.kt +++ b/Confidence/src/test/java/com/spotify/confidence/FileDiskStorageTest.kt @@ -3,6 +3,7 @@ package com.spotify.confidence import com.spotify.confidence.apply.ApplyInstance import com.spotify.confidence.apply.EventStatus import com.spotify.confidence.cache.FileDiskStorage +import com.spotify.confidence.client.Clock import org.junit.Assert import org.junit.Before import org.junit.Test @@ -17,6 +18,7 @@ class FileDiskStorageTest { private lateinit var diskStorage: FileDiskStorage private lateinit var flagsFile: File private lateinit var applyFile: File + private lateinit var resolveMetadataFile: File val badFile = "{\"context\":{\"name\":\"emu64a\",\"version\":\"1.0\",\"density\":2.625,\"height\":1857.0,\"width\":1080,\"namespace\":\"com.example.confidencedemoapp\",\"build\":\"1\",\"manufacturer\":\"Google\",\"model\":\"sdk_gphone64_arm64\",\"type\":\"android\",\"targeting_key\":\"a98a4291-53b0-49d9-bae8-73d3f5da2070\"},\"flags\":[{\"flag\":\"hatten\",\"variant\":\"\",\"reason\":\"RESOLVE_REASON_NO_SEGMENT_MATCH\"}],\"resolveToken\":\"meh\"}" val badApply = "{\"apply\":{\"apply\":\"apply\"}" @@ -25,7 +27,9 @@ class FileDiskStorageTest { fun setup() { flagsFile = Files.createTempFile("flags", ".txt").toFile() applyFile = Files.createTempFile("apply", ".txt").toFile() - diskStorage = FileDiskStorage(flagsFile, applyFile) + resolveMetadataFile = Files.createTempFile("resolve-metadata", ".txt").toFile() + resolveMetadataFile.delete() + diskStorage = FileDiskStorage(flagsFile, applyFile, resolveMetadataFile) } @Test @@ -207,4 +211,45 @@ class FileDiskStorageTest { val result = diskStorage.read() Assert.assertEquals(FlagResolution.EMPTY, result) } + + @Test + fun testLastFetchedAtIsPersisted() { + val fetchedAt = Date(1_234) + diskStorage = FileDiskStorage( + flagsFile, + applyFile, + resolveMetadataFile, + object : Clock { + override fun currentTime(): Date = fetchedAt + } + ) + + diskStorage.markFetched() + + Assert.assertEquals(fetchedAt, diskStorage.getLastFetchedAt()) + } + + @Test + fun testMissingOrInvalidLastFetchedAtReturnsNull() { + Assert.assertNull(diskStorage.getLastFetchedAt()) + resolveMetadataFile.writeText("not-json") + + Assert.assertNull(diskStorage.getLastFetchedAt()) + } + + @Test + fun testUnknownMetadataFieldsAreIgnored() { + resolveMetadataFile.writeText("""{"lastFetchedAtMillis":1234,"futureField":true}""") + + Assert.assertEquals(Date(1_234), diskStorage.getLastFetchedAt()) + } + + @Test + fun testClearRemovesLastFetchedAt() { + diskStorage.markFetched() + + diskStorage.clear() + + Assert.assertNull(diskStorage.getLastFetchedAt()) + } } diff --git a/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt b/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt new file mode 100644 index 00000000..042247b1 --- /dev/null +++ b/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt @@ -0,0 +1,63 @@ +package com.spotify.confidence + +import com.spotify.confidence.client.Clock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.util.Date + +class ResolveStorageStatusTest { + private val now = Date(10_000) + private val clock = object : Clock { + override fun currentTime(): Date = now + } + + @Test + fun unknownFetchTimeIsStale() { + val check = MaxAgeStorageCheck(1_000, clock) + + assertEquals( + ResolveStorageStatus.Stale(null), + check.check(ResolveStorageMetadata(isEmpty = false, lastFetchedAt = null)) + ) + } + + @Test + fun fetchWithinMaxAgeIsFresh() { + val check = MaxAgeStorageCheck(1_000, clock) + val lastFetchedAt = Date(9_001) + + assertEquals( + ResolveStorageStatus.Fresh(lastFetchedAt), + check.check(ResolveStorageMetadata(isEmpty = false, lastFetchedAt = lastFetchedAt)) + ) + } + + @Test + fun fetchAtMaxAgeIsStale() { + val check = MaxAgeStorageCheck(1_000, clock) + val lastFetchedAt = Date(9_000) + + assertEquals( + ResolveStorageStatus.Stale(lastFetchedAt), + check.check(ResolveStorageMetadata(isEmpty = false, lastFetchedAt = lastFetchedAt)) + ) + } + + @Test + fun emptyStorageIsEmpty() { + val check = MaxAgeStorageCheck(1_000, clock) + + assertEquals( + ResolveStorageStatus.Empty, + check.check(ResolveStorageMetadata(isEmpty = true, lastFetchedAt = null)) + ) + } + + @Test + fun maxAgeMustBePositive() { + assertThrows(IllegalArgumentException::class.java) { + MaxAgeStorageCheck(0) + } + } +} diff --git a/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt b/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt index a4811561..ab180021 100644 --- a/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt +++ b/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt @@ -17,6 +17,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.nio.file.Files import java.time.Instant +import java.util.Date class StorageFileCacheTests { private val instant = Instant.parse("2023-03-01T14:01:46.999Z") @@ -109,6 +110,39 @@ class StorageFileCacheTests { TestCase.assertEquals(ResolveReason.RESOLVE_REASON_MATCH, evalNull.reason) } + @Test + fun testStorageProvidesEmptyMetadataToStorageCheck() { + val confidence = getConfidence(UnconfinedTestDispatcher()) + var metadata: ResolveStorageMetadata? = null + + val status = confidence.getStorageStatus { receivedMetadata -> + metadata = receivedMetadata + ResolveStorageStatus.Empty + } + + TestCase.assertEquals(ResolveStorageStatus.Empty, status) + TestCase.assertEquals(ResolveStorageMetadata(isEmpty = true, lastFetchedAt = null), metadata) + } + + @Test + fun testSuccessfulFetchStoresLastFetchedAt() = runTest { + val context = mapOf("user_id" to ConfidenceValue.String("user1")) + val confidence = getConfidence(UnconfinedTestDispatcher(), initialContext = context) + whenever(flagResolverClient.resolve(eq(listOf()), any())).thenReturn( + Result.Success(FlagResolution(context, resolvedFlags.list, "token1")) + ) + + confidence.fetchAndActivate() + var lastFetchedAt: Date? = null + val status = confidence.getStorageStatus { + lastFetchedAt = it.lastFetchedAt + ResolveStorageStatus.Fresh(it.lastFetchedAt) + } + + TestCase.assertNotNull(lastFetchedAt) + TestCase.assertEquals(ResolveStorageStatus.Fresh(lastFetchedAt), status) + } + private fun getConfidence( dispatcher: CoroutineDispatcher, cache: ProviderCache = mock(), diff --git a/README.md b/README.md index 5968ad86..726f4750 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,26 @@ Notes: - Setting a new evaluation context with `OpenFeatureAPI.setEvaluationContextAndWait(...)` will fetch and activate new values before returning. - With `ActivateAndFetchAsync`, the background fetch stores new flags to disk but does not swap in-memory values during the session unless a subsequent activation occurs (for example when the context changes via `setEvaluationContextAndWait`). +Applications can select a strategy based on the age of the cached resolve storage: + +```kotlin +val storageStatus = confidence.getStorageStatus( + MaxAgeStorageCheck(maxAgeMillis = TimeUnit.HOURS.toMillis(24)) +) +val strategy = when (storageStatus) { + ResolveStorageStatus.Empty, + is ResolveStorageStatus.Stale -> InitialisationStrategy.FetchAndActivate + is ResolveStorageStatus.Fresh -> InitialisationStrategy.ActivateAndFetchAsync +} + +val provider = ConfidenceFeatureProvider.create( + confidence, + initialisationStrategy = strategy +) +``` + +`MaxAgeStorageCheck` reports cached data with an unknown fetch time as stale. For custom staleness rules, pass your own `ResolveStorageCheck` implementation to `getStorageStatus`. + ### Setting the context The evaluation context is a key-value map that will be used for sampling and for targeting input in assigning feature flag values by the Confidence backend. It is also a crucial way to create dimensions for metrics generated by event data. From 5178081120c7a4dbb0d3c67f7a46b0225b16fa8b Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 10 Sep 2026 11:23:02 +0200 Subject: [PATCH 2/6] refactor: enrich resolve storage metadata --- Confidence/api/Confidence.api | 8 +++++--- .../spotify/confidence/ResolveStorageStatus.kt | 3 ++- .../cache/ResolveStorageMetadataStorage.kt | 12 ++++++++---- .../confidence/ResolveStorageStatusTest.kt | 17 +++++++++++++---- .../spotify/confidence/StorageFileCacheTests.kt | 15 +++++++++------ 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/Confidence/api/Confidence.api b/Confidence/api/Confidence.api index 9e96601d..595efe78 100644 --- a/Confidence/api/Confidence.api +++ b/Confidence/api/Confidence.api @@ -509,12 +509,14 @@ public abstract interface class com/spotify/confidence/ResolveStorageCheck { } public final class com/spotify/confidence/ResolveStorageMetadata { - public fun (ZLjava/util/Date;)V + public fun (ZLjava/util/Date;Ljava/util/Map;)V public final fun component1 ()Z public final fun component2 ()Ljava/util/Date; - public final fun copy (ZLjava/util/Date;)Lcom/spotify/confidence/ResolveStorageMetadata; - public static synthetic fun copy$default (Lcom/spotify/confidence/ResolveStorageMetadata;ZLjava/util/Date;ILjava/lang/Object;)Lcom/spotify/confidence/ResolveStorageMetadata; + public final fun component3 ()Ljava/util/Map; + public final fun copy (ZLjava/util/Date;Ljava/util/Map;)Lcom/spotify/confidence/ResolveStorageMetadata; + public static synthetic fun copy$default (Lcom/spotify/confidence/ResolveStorageMetadata;ZLjava/util/Date;Ljava/util/Map;ILjava/lang/Object;)Lcom/spotify/confidence/ResolveStorageMetadata; public fun equals (Ljava/lang/Object;)Z + public final fun getContext ()Ljava/util/Map; public final fun getLastFetchedAt ()Ljava/util/Date; public fun hashCode ()I public final fun isEmpty ()Z diff --git a/Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt b/Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt index 0dfcba23..e75901c2 100644 --- a/Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt +++ b/Confidence/src/main/java/com/spotify/confidence/ResolveStorageStatus.kt @@ -24,7 +24,8 @@ fun interface ResolveStorageCheck { /** Metadata available when checking resolve storage. */ data class ResolveStorageMetadata( val isEmpty: Boolean, - val lastFetchedAt: Date? + val lastFetchedAt: Date?, + val context: Map ) /** Considers resolve storage stale when its fetch time is unknown or at least [maxAgeMillis] old. */ diff --git a/Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt b/Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt index acd52808..a570d5d2 100644 --- a/Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt +++ b/Confidence/src/main/java/com/spotify/confidence/cache/ResolveStorageMetadataStorage.kt @@ -16,7 +16,11 @@ internal interface ResolveStorageMetadataStorage { fun getLastFetchedAt(): Date? } -internal fun DiskStorage.getResolveStorageMetadata(): ResolveStorageMetadata = ResolveStorageMetadata( - isEmpty = read() == FlagResolution.EMPTY, - lastFetchedAt = (this as? ResolveStorageMetadataStorage)?.getLastFetchedAt()?.let { Date(it.time) } -) +internal fun DiskStorage.getResolveStorageMetadata(): ResolveStorageMetadata { + val resolution = read() + return ResolveStorageMetadata( + isEmpty = resolution == FlagResolution.EMPTY, + lastFetchedAt = (this as? ResolveStorageMetadataStorage)?.getLastFetchedAt()?.let { Date(it.time) }, + context = resolution.context.toMap() + ) +} diff --git a/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt b/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt index 042247b1..93d238cf 100644 --- a/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt +++ b/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt @@ -18,7 +18,7 @@ class ResolveStorageStatusTest { assertEquals( ResolveStorageStatus.Stale(null), - check.check(ResolveStorageMetadata(isEmpty = false, lastFetchedAt = null)) + check.check(resolveStorageMetadata(lastFetchedAt = null)) ) } @@ -29,7 +29,7 @@ class ResolveStorageStatusTest { assertEquals( ResolveStorageStatus.Fresh(lastFetchedAt), - check.check(ResolveStorageMetadata(isEmpty = false, lastFetchedAt = lastFetchedAt)) + check.check(resolveStorageMetadata(lastFetchedAt = lastFetchedAt)) ) } @@ -40,7 +40,7 @@ class ResolveStorageStatusTest { assertEquals( ResolveStorageStatus.Stale(lastFetchedAt), - check.check(ResolveStorageMetadata(isEmpty = false, lastFetchedAt = lastFetchedAt)) + check.check(resolveStorageMetadata(lastFetchedAt = lastFetchedAt)) ) } @@ -50,7 +50,7 @@ class ResolveStorageStatusTest { assertEquals( ResolveStorageStatus.Empty, - check.check(ResolveStorageMetadata(isEmpty = true, lastFetchedAt = null)) + check.check(resolveStorageMetadata(isEmpty = true, lastFetchedAt = null)) ) } @@ -60,4 +60,13 @@ class ResolveStorageStatusTest { MaxAgeStorageCheck(0) } } + + private fun resolveStorageMetadata( + isEmpty: Boolean = false, + lastFetchedAt: Date? + ) = ResolveStorageMetadata( + isEmpty = isEmpty, + lastFetchedAt = lastFetchedAt, + context = emptyMap() + ) } diff --git a/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt b/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt index ab180021..00854803 100644 --- a/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt +++ b/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt @@ -17,7 +17,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.nio.file.Files import java.time.Instant -import java.util.Date class StorageFileCacheTests { private val instant = Instant.parse("2023-03-01T14:01:46.999Z") @@ -121,7 +120,10 @@ class StorageFileCacheTests { } TestCase.assertEquals(ResolveStorageStatus.Empty, status) - TestCase.assertEquals(ResolveStorageMetadata(isEmpty = true, lastFetchedAt = null), metadata) + TestCase.assertEquals( + ResolveStorageMetadata(isEmpty = true, lastFetchedAt = null, context = emptyMap()), + metadata + ) } @Test @@ -133,14 +135,15 @@ class StorageFileCacheTests { ) confidence.fetchAndActivate() - var lastFetchedAt: Date? = null + var metadata: ResolveStorageMetadata? = null val status = confidence.getStorageStatus { - lastFetchedAt = it.lastFetchedAt + metadata = it ResolveStorageStatus.Fresh(it.lastFetchedAt) } - TestCase.assertNotNull(lastFetchedAt) - TestCase.assertEquals(ResolveStorageStatus.Fresh(lastFetchedAt), status) + TestCase.assertNotNull(metadata?.lastFetchedAt) + TestCase.assertEquals(context, metadata?.context) + TestCase.assertEquals(ResolveStorageStatus.Fresh(metadata?.lastFetchedAt), status) } private fun getConfidence( From 44145d8f504c0931995b1c2087d374a7d28860af Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 10 Sep 2026 11:55:59 +0200 Subject: [PATCH 3/6] test: cover future resolve fetch timestamp --- .../spotify/confidence/ResolveStorageStatusTest.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt b/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt index 93d238cf..020f8b40 100644 --- a/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt +++ b/Confidence/src/test/java/com/spotify/confidence/ResolveStorageStatusTest.kt @@ -44,6 +44,17 @@ class ResolveStorageStatusTest { ) } + @Test + fun futureFetchTimeIsFresh() { + val check = MaxAgeStorageCheck(1_000, clock) + val lastFetchedAt = Date(11_000) + + assertEquals( + ResolveStorageStatus.Fresh(lastFetchedAt), + check.check(resolveStorageMetadata(lastFetchedAt = lastFetchedAt)) + ) + } + @Test fun emptyStorageIsEmpty() { val check = MaxAgeStorageCheck(1_000, clock) From fb4263383be4840b79624af1e1e096100980eebc Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 10 Sep 2026 12:02:13 +0200 Subject: [PATCH 4/6] docs: show context-aware storage check --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 726f4750..3bf6e8e7 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,21 @@ val provider = ConfidenceFeatureProvider.create( ) ``` -`MaxAgeStorageCheck` reports cached data with an unknown fetch time as stale. For custom staleness rules, pass your own `ResolveStorageCheck` implementation to `getStorageStatus`. +`MaxAgeStorageCheck` reports cached data with an unknown fetch time as stale. For custom staleness rules, pass your own `ResolveStorageCheck` implementation to `getStorageStatus`. The check receives `ResolveStorageMetadata`, containing whether the storage is empty, when it was last fetched, and the evaluation context used for the cached resolve: + +```kotlin +val expectedTargetingKey = ConfidenceValue.String("user-123") +val contextAwareCheck = ResolveStorageCheck { metadata -> + when { + metadata.isEmpty -> ResolveStorageStatus.Empty + metadata.context["targeting_key"] != expectedTargetingKey -> + ResolveStorageStatus.Stale(metadata.lastFetchedAt) + else -> MaxAgeStorageCheck(TimeUnit.HOURS.toMillis(24)).check(metadata) + } +} + +val storageStatus = confidence.getStorageStatus(contextAwareCheck) +``` ### Setting the context From b8dc947a4658efa775ba13dcac3186760a978a89 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 10 Sep 2026 12:06:58 +0200 Subject: [PATCH 5/6] fix: snapshot resolve context --- .../java/com/spotify/confidence/Confidence.kt | 2 +- .../com/spotify/confidence/PayloadMerger.kt | 4 +-- .../confidence/StorageFileCacheTests.kt | 30 +++++++++++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt index 440586fe..973c0456 100644 --- a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt +++ b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt @@ -339,7 +339,7 @@ class Confidence internal constructor( private suspend fun fetchAndStore(failOnStaleResponse: Boolean = false): Result { try { // Empty/not-modified responses do not carry their context, so retain the context used for the request. - val resolveContext = getContext() + val resolveContext = getContext().snapshot() return when (val resolveResponse = resolve(listOf(), resolveContext)) { is Result.Success -> { val staleResponse = resolveResponse.data != FlagResolution.EMPTY && diff --git a/Confidence/src/main/java/com/spotify/confidence/PayloadMerger.kt b/Confidence/src/main/java/com/spotify/confidence/PayloadMerger.kt index 7cb9c71c..65fd1434 100644 --- a/Confidence/src/main/java/com/spotify/confidence/PayloadMerger.kt +++ b/Confidence/src/main/java/com/spotify/confidence/PayloadMerger.kt @@ -19,9 +19,9 @@ internal class PayloadMergerImpl( } } -private fun ConfidenceStruct.snapshot(): ConfidenceStruct = mapValues { (_, value) -> value.snapshot() } +internal fun ConfidenceStruct.snapshot(): ConfidenceStruct = mapValues { (_, value) -> value.snapshot() } -private fun ConfidenceValue.snapshot(): ConfidenceValue = when (this) { +internal fun ConfidenceValue.snapshot(): ConfidenceValue = when (this) { is ConfidenceValue.Struct -> ConfidenceValue.Struct(map.snapshot()) is ConfidenceValue.List -> ConfidenceValue.List(list.map { it.snapshot() }) is ConfidenceValue.Date -> ConfidenceValue.Date(java.util.Date(date.time)) diff --git a/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt b/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt index 00854803..13409711 100644 --- a/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt +++ b/Confidence/src/test/java/com/spotify/confidence/StorageFileCacheTests.kt @@ -146,18 +146,44 @@ class StorageFileCacheTests { TestCase.assertEquals(ResolveStorageStatus.Fresh(metadata?.lastFetchedAt), status) } + @Test + fun testFetchUsesDeepContextSnapshot() = runTest { + val nestedContext = mutableMapOf( + "plan" to ConfidenceValue.String("free") + ) + val resolver = object : FlagResolver { + override suspend fun resolve( + flags: List, + context: Map + ): Result { + nestedContext["plan"] = ConfidenceValue.String("premium") + val resolvedPlan = ((context["user"] as ConfidenceValue.Struct).map["plan"]) + TestCase.assertEquals(ConfidenceValue.String("free"), resolvedPlan) + return Result.Success(FlagResolution(context, emptyList(), "token")) + } + } + val confidence = getConfidence( + dispatcher = UnconfinedTestDispatcher(), + initialContext = mapOf("user" to ConfidenceValue.Struct(nestedContext)), + flagResolver = resolver + ) + + confidence.fetchAndActivate() + } + private fun getConfidence( dispatcher: CoroutineDispatcher, cache: ProviderCache = mock(), initialContext: Map = mapOf(), - debugLogger: DebugLoggerFake = DebugLoggerFake() + debugLogger: DebugLoggerFake = DebugLoggerFake(), + flagResolver: FlagResolver = flagResolverClient ) = Confidence( clientSecret = "", dispatcher = dispatcher, eventSenderEngine = mock(), cache = cache, initialContext = initialContext, - flagResolver = flagResolverClient, + flagResolver = flagResolver, flagApplierClient = mock(), diskStorage = FileDiskStorage.create(mockContext), region = ConfidenceRegion.EUROPE, From d6d8bbf80ce6ffc76afd59300e20cddcb3db77f0 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 10 Sep 2026 13:22:29 +0200 Subject: [PATCH 6/6] feat: showcase storage status in demo app --- .../example/confidencedemoapp/MainActivity.kt | 7 ++++- .../com/example/confidencedemoapp/MainVm.kt | 30 +++++++++++++++++-- README.md | 2 ++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainActivity.kt b/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainActivity.kt index e9af25ca..3f9433fb 100644 --- a/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainActivity.kt +++ b/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainActivity.kt @@ -29,6 +29,7 @@ class MainActivity : ComponentActivity() { val msgState = vm.message.observeAsState() val colorState = vm.color.observeAsState() val surfaceText = vm.surfaceText.observeAsState() + val initialisationInfo = vm.initialisationInfo.observeAsState() MyApplicationTheme { Surface( @@ -37,6 +38,10 @@ class MainActivity : ComponentActivity() { ) { Column { Greeting(msgState.value.toString()) + Text( + text = initialisationInfo.value ?: "N/A", + modifier = Modifier.padding(8.dp) + ) Button(onClick = { vm.refreshUi() @@ -77,4 +82,4 @@ class MainActivity : ComponentActivity() { @Composable fun Greeting(name: String) { Text(text = "Hello $name!") -} \ No newline at end of file +} diff --git a/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt b/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt index e4123207..1ac6befb 100644 --- a/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt +++ b/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt @@ -15,6 +15,7 @@ import com.spotify.confidence.openfeature.InitialisationStrategy import dev.openfeature.kotlin.sdk.* import kotlinx.coroutines.launch import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -22,14 +23,17 @@ class MainVm(app: Application) : AndroidViewModel(app) { companion object { private val TAG = MainVm::class.java.simpleName + private val resolveStorageMaxAgeMillis = TimeUnit.MINUTES.toMillis(1) } private val _message: MutableLiveData = MutableLiveData("initial") private val _color: MutableLiveData = MutableLiveData(Color.Gray) private val _surfaceText: MutableLiveData = MutableLiveData("This is a surface text") + private val _initialisationInfo: MutableLiveData = MutableLiveData("Checking resolve storage") val message: LiveData = _message val color: LiveData = _color val surfaceText: LiveData = _surfaceText + val initialisationInfo: LiveData = _initialisationInfo init { val start = System.currentTimeMillis() @@ -50,9 +54,20 @@ class MainVm(app: Application) : AndroidViewModel(app) { ConfidenceRegion.EUROPE, loggingLevel = LoggingLevel.VERBOSE ) + val storageStatus = confidence.getStorageStatus( + MaxAgeStorageCheck(resolveStorageMaxAgeMillis) + ) + val initialisationStrategy = when (storageStatus) { + ResolveStorageStatus.Empty, + is ResolveStorageStatus.Stale -> InitialisationStrategy.FetchAndActivate + is ResolveStorageStatus.Fresh -> InitialisationStrategy.ActivateAndFetchAsync + } + _initialisationInfo.value = + "Resolve storage: ${storageStatus.description()}\n" + + "Strategy: ${initialisationStrategy.description()}" val provider = ConfidenceFeatureProvider.create( confidence, - initialisationStrategy = InitialisationStrategy.FetchAndActivate + initialisationStrategy = initialisationStrategy ) viewModelScope.launch { @@ -117,6 +132,17 @@ class MainVm(app: Application) : AndroidViewModel(app) { } } +private fun ResolveStorageStatus.description(): String = when (this) { + ResolveStorageStatus.Empty -> "empty" + is ResolveStorageStatus.Fresh -> "fresh (last fetched $lastFetchedAt)" + is ResolveStorageStatus.Stale -> "stale (last fetched $lastFetchedAt)" +} + +private fun InitialisationStrategy.description(): String = when (this) { + InitialisationStrategy.FetchAndActivate -> "FetchAndActivate" + InitialisationStrategy.ActivateAndFetchAsync -> "ActivateAndFetchAsync" +} + private fun FlagEvaluationDetails.toComposeColor(): Color { if (errorCode != null) return Color.Red return when (value.toString().lowercase()) { @@ -143,4 +169,4 @@ private fun Value.friendlyString(): String { is Value.List -> this.list.joinToString(prefix = "[", postfix = "]") { it.friendlyString() } Value.Null -> "" } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 3bf6e8e7..bb1c77a3 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ val contextAwareCheck = ResolveStorageCheck { metadata -> val storageStatus = confidence.getStorageStatus(contextAwareCheck) ``` +To try this flow locally, set `CLIENT_SECRET=` in `local.properties` and run the `ConfidenceDemoApp` configuration. The demo uses a one-minute maximum age and displays both the resolve storage status and selected initialization strategy. Clear the app data for an empty-storage launch, restart within a minute for a fresh launch, or wait a minute before restarting for a stale launch. + ### Setting the context The evaluation context is a key-value map that will be used for sampling and for targeting input in assigning feature flag values by the Confidence backend. It is also a crucial way to create dimensions for metrics generated by event data.