Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions Confidence/api/Confidence.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <init> (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;
Expand All @@ -497,6 +504,54 @@ 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 <init> (ZLjava/util/Date;Ljava/util/Map;)V
public final fun component1 ()Z
public final fun component2 ()Ljava/util/Date;
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
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 <init> (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 <init> (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 {
}

Expand Down
39 changes: 33 additions & 6 deletions Confidence/src/main/java/com/spotify/confidence/Confidence.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,12 +61,15 @@ class Confidence internal constructor(
diskStorage = diskStorage
)

private suspend fun resolve(flags: List<String>): Result<FlagResolution> {
private suspend fun resolve(
flags: List<String>,
resolveContext: Map<String, ConfidenceValue>
): Result<FlagResolution> {
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")
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -327,12 +338,19 @@ class Confidence internal constructor(

private suspend fun fetchAndStore(failOnStaleResponse: Boolean = false): Result<Unit> {
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().snapshot()
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"
Expand All @@ -345,6 +363,7 @@ class Confidence internal constructor(
}
else -> {
diskStorage.store(resolveResponse.data)
markStorageFetched()
Result.Success(Unit)
}
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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?,
val context: Map<String, ConfidenceValue>
)

/** 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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we have an interface for getting time then we could easily mock it in the tests.

) : DiskStorage, ResolveStorageMetadataStorage {
private val lock = ReentrantReadWriteLock()
private val metadataJson = Json { ignoreUnknownKeys = true }

override fun store(flagResolution: FlagResolution) {
write(Json.encodeToString(flagResolution))
Expand All @@ -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<StoredResolveStorageMetadata>(resolveMetadataFile.readText())
.lastFetchedAtMillis
.let(::Date)
}.getOrNull()
}

private fun write(data: String) = lock.write {
flagsFile.writeText(data)
}

override fun writeApplyData(applyData: Map<String, MutableMap<String, ApplyInstance>>) {
applyFile.writeText(Json.encodeToString(applyData))
}
Expand All @@ -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() }
Expand All @@ -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
}
}
Expand All @@ -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)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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 {
val resolution = read()
return ResolveStorageMetadata(
isEmpty = resolution == FlagResolution.EMPTY,
lastFetchedAt = (this as? ResolveStorageMetadataStorage)?.getLastFetchedAt()?.let { Date(it.time) },
context = resolution.context.toMap()
)
}
Loading
Loading