diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt index 525b45e9..75cc3912 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QRemoteConfigManager.kt @@ -306,7 +306,7 @@ internal class QRemoteConfigManager @Inject constructor( contextKey, loadingState, generationAtStart, - requestIdentity.cacheScope, + requestIdentity, remoteConfig, ) } else { @@ -326,7 +326,19 @@ internal class QRemoteConfigManager @Inject constructor( override fun onError(error: QonversionError) { postIdentityAction { if (requestIdentity.isCurrentAndStable()) { - handleRemoteConfigError(contextKey, loadingState, requestIdentity.cacheScope, error) + if (error.code == QonversionErrorCode.RemoteConfigurationNotAvailable && + requestIdentity.cacheScope != null + ) { + handleAuthoritativeRemoteConfigRemoval( + contextKey, + loadingState, + generationAtStart, + requestIdentity, + error, + ) + } else { + handleRemoteConfigError(contextKey, loadingState, requestIdentity.cacheScope, error) + } } else { reissueSingleAfterUserChange(contextKey, loadingState) } @@ -335,6 +347,38 @@ internal class QRemoteConfigManager @Inject constructor( }) } + private fun handleAuthoritativeRemoteConfigRemoval( + contextKey: String?, + loadingState: LoadingState, + generationAtStart: Int, + requestIdentity: RemoteConfigRequestIdentity, + error: QonversionError, + ) { + if (invalidationGeneration.get() != generationAtStart) { + reissueSingleAfterUserChange(contextKey, loadingState) + return + } + val cacheScope = requestIdentity.cacheScope ?: run { + handleRemoteConfigError(contextKey, loadingState, null, error) + return + } + persistentCache.remove(cacheScope, contextKey) { committed -> + postIdentityAction { + when { + !requestIdentity.isCurrentAndStable() -> + reissueSingleAfterUserChange(contextKey, loadingState) + invalidationGeneration.get() != generationAtStart -> + reissueSingleAfterUserChange(contextKey, loadingState) + committed -> handleRemoteConfigError(contextKey, loadingState, cacheScope, error) + else -> { + loadingState.retryBaseline = null + fireToCallbacks(contextKey) { onError(remoteConfigPersistenceError()) } + } + } + } + } + } + private fun reissueSingleAfterUserChange( contextKey: String?, supersededState: LoadingState, @@ -351,13 +395,69 @@ internal class QRemoteConfigManager @Inject constructor( contextKey: String?, loadingState: LoadingState, generationAtStart: Int, - cacheScope: RemoteConfigCacheScope?, + requestIdentity: RemoteConfigRequestIdentity, remoteConfig: QRemoteConfig, ) { loadingState.retryBaseline = null + val currentGeneration = invalidationGeneration.get() + if (currentGeneration != generationAtStart) { + deliverOrReissueRemoteConfigSuccess( + contextKey, + loadingState, + generationAtStart, + remoteConfig, + ) + return + } + + val cacheScope = requestIdentity.cacheScope + if (cacheScope == null) { + deliverOrReissueRemoteConfigSuccess( + contextKey, + loadingState, + generationAtStart, + remoteConfig, + ) + return + } + + persistentCache.save(cacheScope, remoteConfig) { committed -> + postIdentityAction { + when { + !requestIdentity.isCurrentAndStable() -> + reissueSingleAfterUserChange(contextKey, loadingState) + invalidationGeneration.get() != generationAtStart -> + deliverOrReissueRemoteConfigSuccess( + contextKey, + loadingState, + generationAtStart, + remoteConfig, + ) + committed -> deliverOrReissueRemoteConfigSuccess( + contextKey, + loadingState, + generationAtStart, + remoteConfig, + ) + else -> handleRemoteConfigError( + contextKey, + loadingState, + cacheScope, + remoteConfigPersistenceError(), + ) + } + } + } + } + + private fun deliverOrReissueRemoteConfigSuccess( + contextKey: String?, + loadingState: LoadingState, + generationAtStart: Int, + remoteConfig: QRemoteConfig, + ) { val currentGeneration = invalidationGeneration.get() if (currentGeneration == generationAtStart) { - cacheScope?.let { persistentCache.save(it, remoteConfig) } deliveryOrigins[contextKey] = QRemoteConfigDeliveryOrigin.Network loadingState.loadedConfig = remoteConfig loadingState.generation = generationAtStart @@ -418,12 +518,6 @@ internal class QRemoteConfigManager @Inject constructor( ) { val baseline = loadingState.retryBaseline loadingState.retryBaseline = null - if (error.code == QonversionErrorCode.RemoteConfigurationNotAvailable) { - // The server authoritatively evaluated this context and found no - // config. Keeping the old disk value would resurrect a removed - // assignment on the next transient outage. - cacheScope?.let { persistentCache.remove(it, contextKey) } - } val canRecover = error.shouldFireRemoteConfigFallback val lastKnownGood = if (canRecover && cacheScope != null) { persistentCache.get(cacheScope, contextKey) @@ -619,7 +713,7 @@ internal class QRemoteConfigManager @Inject constructor( contextKeys, includeEmptyContextKey, callback, - requestIdentity.cacheScope, + requestIdentity, generationAtStart, localLoadingStates, remoteConfigList, @@ -665,11 +759,72 @@ internal class QRemoteConfigManager @Inject constructor( "Remote Config response does not match the request", ) + private fun remoteConfigPersistenceError() = QonversionError( + QonversionErrorCode.ResponseParsingFailed, + "Remote Config could not be persisted as last known good", + ) + private fun handleRemoteConfigListSuccess( contextKeys: List?, includeEmptyContextKey: Boolean, callback: QonversionRemoteConfigListCallback, - cacheScope: RemoteConfigCacheScope?, + requestIdentity: RemoteConfigRequestIdentity, + generationAtStart: Int, + localLoadingStates: MutableMap, + remoteConfigList: QRemoteConfigList, + ) { + if (invalidationGeneration.get() != generationAtStart) { + // Preserve the legacy list contract: an already-valid response is + // still delivered, but a superseded evaluation is never promoted + // into either the in-memory cache or the persistent LKG. + remoteConfigList.remoteConfigs.forEach { remoteConfig -> + deliveryOrigins[remoteConfig.source.contextKey] = QRemoteConfigDeliveryOrigin.Network + } + callback.onSuccess(remoteConfigList) + return + } + + val cacheScope = requestIdentity.cacheScope + if (cacheScope == null) { + completeRemoteConfigListSuccess( + callback, + generationAtStart, + localLoadingStates, + remoteConfigList, + ) + return + } + + reconcilePersistentCache( + contextKeys, + includeEmptyContextKey, + cacheScope, + remoteConfigList.remoteConfigs, + ) { committed -> + postIdentityAction { + when { + !requestIdentity.isCurrentAndStable() -> + reissueRemoteConfigListAfterUserChange(contextKeys, includeEmptyContextKey, callback) + invalidationGeneration.get() != generationAtStart -> + reissueRemoteConfigList(contextKeys, includeEmptyContextKey, callback) + committed -> completeRemoteConfigListSuccess( + callback, + generationAtStart, + localLoadingStates, + remoteConfigList, + ) + else -> remoteConfigListFallback( + contextKeys, + includeEmptyContextKey, + cacheScope, + )?.let(callback::onSuccess) ?: callback.onError(remoteConfigPersistenceError()) + } + } + } + } + + private fun completeRemoteConfigListSuccess( + callback: QonversionRemoteConfigListCallback, generationAtStart: Int, localLoadingStates: MutableMap, remoteConfigList: QRemoteConfigList, @@ -677,22 +832,12 @@ internal class QRemoteConfigManager @Inject constructor( remoteConfigList.remoteConfigs.forEach { remoteConfig -> deliveryOrigins[remoteConfig.source.contextKey] = QRemoteConfigDeliveryOrigin.Network } - if (invalidationGeneration.get() == generationAtStart) { - cacheScope?.let { - reconcilePersistentCache( - contextKeys, - includeEmptyContextKey, - it, - remoteConfigList.remoteConfigs, - ) - } - remoteConfigList.remoteConfigs.forEach { remoteConfig -> - val contextKey = remoteConfig.source.contextKey - val loadingState = localLoadingStates[contextKey] ?: LoadingState() - loadingState.loadedConfig = remoteConfig - loadingState.generation = generationAtStart - localLoadingStates[contextKey] = loadingState - } + remoteConfigList.remoteConfigs.forEach { remoteConfig -> + val contextKey = remoteConfig.source.contextKey + val loadingState = localLoadingStates[contextKey] ?: LoadingState() + loadingState.loadedConfig = remoteConfig + loadingState.generation = generationAtStart + localLoadingStates[contextKey] = loadingState } callback.onSuccess(remoteConfigList) @@ -703,9 +848,10 @@ internal class QRemoteConfigManager @Inject constructor( includeEmptyContextKey: Boolean, cacheScope: RemoteConfigCacheScope, remoteConfigs: List, + completion: (Boolean) -> Unit, ) { if (contextKeys == null) { - persistentCache.replaceAll(cacheScope, remoteConfigs) + persistentCache.replaceAll(cacheScope, remoteConfigs, completion) return } @@ -713,7 +859,7 @@ internal class QRemoteConfigManager @Inject constructor( addAll(contextKeys) if (includeEmptyContextKey) add(null) }.toSet() - persistentCache.replaceRequested(cacheScope, requestedContextKeys, remoteConfigs) + persistentCache.replaceRequested(cacheScope, requestedContextKeys, remoteConfigs, completion) } private fun remoteConfigListFallback( diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt index ab46b470..9f887c25 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/Cache.kt @@ -33,6 +33,10 @@ internal interface Cache { values.forEach(::putString) } + fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + throw UnsupportedOperationException("This cache does not provide durable atomic string updates") + } + /** * @param defValue is returned if the String preference for key does not exist */ diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt index fd5d0469..05a77d39 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCache.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi import java.security.MessageDigest import java.util.concurrent.Executor import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException private const val DEFAULT_MAX_REMOTE_CONFIG_CACHE_BYTES = 512 * 1024 private const val MAX_REMOTE_CONFIG_INDEX_BYTES = 64 * 1024 @@ -36,16 +37,37 @@ internal interface RemoteConfigCache { fun currentScope(): RemoteConfigCacheScope? = null fun save(remoteConfig: QRemoteConfig) fun save(scope: RemoteConfigCacheScope, remoteConfig: QRemoteConfig) = save(remoteConfig) + fun save( + scope: RemoteConfigCacheScope, + remoteConfig: QRemoteConfig, + completion: (Boolean) -> Unit, + ) fun remove(contextKey: String?) fun remove(scope: RemoteConfigCacheScope, contextKey: String?) = remove(contextKey) + fun remove( + scope: RemoteConfigCacheScope, + contextKey: String?, + completion: (Boolean) -> Unit, + ) fun replaceAll(remoteConfigs: List) fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) = replaceAll(remoteConfigs) + fun replaceAll( + scope: RemoteConfigCacheScope, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) fun replaceRequested(requestedContextKeys: Set, remoteConfigs: List) fun replaceRequested( scope: RemoteConfigCacheScope, requestedContextKeys: Set, remoteConfigs: List, ) = replaceRequested(requestedContextKeys, remoteConfigs) + fun replaceRequested( + scope: RemoteConfigCacheScope, + requestedContextKeys: Set, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) fun get(contextKey: String?): QRemoteConfig? fun get(scope: RemoteConfigCacheScope, contextKey: String?): QRemoteConfig? = get(contextKey) fun getAll(): QRemoteConfigList @@ -64,6 +86,8 @@ internal class PersistentRemoteConfigCache( private val indexAdapter = moshi.adapter(PersistentRemoteConfigIndex::class.java) private val memoryEnvelopes = mutableMapOf() private val pendingRevisions = mutableMapOf() + private val pendingEnvelopes = mutableMapOf() + private val pendingCompletions = mutableMapOf>() private var nextRevision = 0L @Synchronized @@ -74,15 +98,41 @@ internal class PersistentRemoteConfigCache( @Synchronized override fun save(scope: RemoteConfigCacheScope, remoteConfig: QRemoteConfig) { - if (!remoteConfig.isCorrect) return + save(scope, remoteConfig, requireExactPersistence = false) {} + } - val currentConfigs = loadEnvelope(scope)?.remoteConfigs.orEmpty() + @Synchronized + override fun save( + scope: RemoteConfigCacheScope, + remoteConfig: QRemoteConfig, + completion: (Boolean) -> Unit, + ) = save(scope, remoteConfig, requireExactPersistence = false, completion) + + private fun save( + scope: RemoteConfigCacheScope, + remoteConfig: QRemoteConfig, + requireExactPersistence: Boolean, + completion: (Boolean) -> Unit, + ) { + if (!remoteConfig.isCorrect) { + completion(false) + return + } + + val currentConfigs = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty() val contextKey = remoteConfig.source.contextKey.normalizedRemoteConfigContextKey() val updatedConfigs = currentConfigs .filterNot { it.source.contextKey.normalizedRemoteConfigContextKey() == contextKey } .plus(remoteConfig) .takeLast(limits.maxEntriesPerScope) - scheduleWrite(scope, updatedConfigs) + scheduleWrite( + scope, + updatedConfigs, + completion, + requireExactPersistence, + ) { committedEnvelope -> + committedEnvelope?.remoteConfigs?.any { it == remoteConfig } == true + } } @Synchronized @@ -93,10 +143,35 @@ internal class PersistentRemoteConfigCache( @Synchronized override fun remove(scope: RemoteConfigCacheScope, contextKey: String?) { + remove(scope, contextKey, requireExactPersistence = false) {} + } + + @Synchronized + override fun remove( + scope: RemoteConfigCacheScope, + contextKey: String?, + completion: (Boolean) -> Unit, + ) = remove(scope, contextKey, requireExactPersistence = true, completion) + + private fun remove( + scope: RemoteConfigCacheScope, + contextKey: String?, + requireExactPersistence: Boolean, + completion: (Boolean) -> Unit, + ) { val normalizedContextKey = contextKey.normalizedRemoteConfigContextKey() - val updatedConfigs = loadEnvelope(scope)?.remoteConfigs.orEmpty() + val updatedConfigs = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty() .filterNot { it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey } - scheduleWrite(scope, updatedConfigs) + scheduleWrite( + scope, + updatedConfigs, + completion, + requireExactPersistence, + ) { committedEnvelope -> + committedEnvelope?.remoteConfigs.orEmpty().none { + it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey + } + } } @Synchronized @@ -107,13 +182,36 @@ internal class PersistentRemoteConfigCache( @Synchronized override fun replaceAll(scope: RemoteConfigCacheScope, remoteConfigs: List) { - if (!remoteConfigs.areValidForPersistence()) return + replaceAll(scope, remoteConfigs, requireExactPersistence = false) {} + } + + @Synchronized + override fun replaceAll( + scope: RemoteConfigCacheScope, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) = replaceAll(scope, remoteConfigs, requireExactPersistence = true, completion) + + private fun replaceAll( + scope: RemoteConfigCacheScope, + remoteConfigs: List, + requireExactPersistence: Boolean, + completion: (Boolean) -> Unit, + ) { + if (!remoteConfigs.areValidForPersistence()) { + completion(false) + return + } + if (requireExactPersistence && remoteConfigs.size > limits.maxEntriesPerScope) { + completion(false) + return + } - val previousEnvelope = loadEnvelope(scope) scheduleWrite( scope, remoteConfigs.takeLast(limits.maxEntriesPerScope), - previousEnvelope, + completion, + requireExactPersistence, ) } @@ -132,27 +230,68 @@ internal class PersistentRemoteConfigCache( requestedContextKeys: Set, remoteConfigs: List, ) { - if (remoteConfigs.any { !it.isCorrect }) return + replaceRequested( + scope, + requestedContextKeys, + remoteConfigs, + requireExactPersistence = false, + ) {} + } + @Synchronized + override fun replaceRequested( + scope: RemoteConfigCacheScope, + requestedContextKeys: Set, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) = replaceRequested( + scope, + requestedContextKeys, + remoteConfigs, + requireExactPersistence = true, + completion, + ) + + private fun replaceRequested( + scope: RemoteConfigCacheScope, + requestedContextKeys: Set, + remoteConfigs: List, + requireExactPersistence: Boolean, + completion: (Boolean) -> Unit, + ) { val normalizedRequestedKeys = requestedContextKeys .mapTo(mutableSetOf()) { it.normalizedRemoteConfigContextKey() } - val returnedKeys = remoteConfigs.map { config -> - config.source.contextKey.normalizedRemoteConfigContextKey() - } - if (returnedKeys.size != returnedKeys.distinct().size || - returnedKeys.any { it !in normalizedRequestedKeys } - ) { + if (!remoteConfigs.areValidForRequestedPersistence(normalizedRequestedKeys)) { + completion(false) return } - val previousEnvelope = loadEnvelope(scope) - val updatedConfigs = previousEnvelope?.remoteConfigs.orEmpty() + val requestedUpdate = loadLatestEnvelope(scope)?.remoteConfigs.orEmpty() .filterNot { config -> config.source.contextKey.normalizedRemoteConfigContextKey() in normalizedRequestedKeys } .plus(remoteConfigs) - .takeLast(limits.maxEntriesPerScope) - scheduleWrite(scope, updatedConfigs, previousEnvelope) + if (requireExactPersistence && requestedUpdate.size > limits.maxEntriesPerScope) { + completion(false) + return + } + val updatedConfigs = requestedUpdate.takeLast(limits.maxEntriesPerScope) + val expectedConfigs = remoteConfigs.associateBy { + it.source.contextKey.normalizedRemoteConfigContextKey() + } + val omittedContextKeys = normalizedRequestedKeys - expectedConfigs.keys + scheduleWrite( + scope, + updatedConfigs, + completion, + requireExactPersistence, + ) { committedEnvelope -> + val committedConfigs = committedEnvelope?.remoteConfigs.orEmpty().associateBy { + it.source.contextKey.normalizedRemoteConfigContextKey() + } + expectedConfigs.all { (contextKey, expected) -> committedConfigs[contextKey] == expected } && + omittedContextKeys.none { it in committedConfigs } + } } @Synchronized @@ -169,12 +308,14 @@ internal class PersistentRemoteConfigCache( it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey } remoteConfig?.let { accessed -> - scheduleWrite( - scope, - envelope.remoteConfigs.filterNot { - it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey - } + accessed, - ) + if (!pendingEnvelopes.containsKey(scope.storageKey)) { + scheduleWrite( + scope, + envelope.remoteConfigs.filterNot { + it.source.contextKey.normalizedRemoteConfigContextKey() == normalizedContextKey + } + accessed, + ) + } } return remoteConfig } @@ -188,7 +329,7 @@ internal class PersistentRemoteConfigCache( @Synchronized override fun getAll(scope: RemoteConfigCacheScope): QRemoteConfigList { val remoteConfigs = loadEnvelope(scope)?.remoteConfigs.orEmpty() - if (remoteConfigs.isNotEmpty()) { + if (remoteConfigs.isNotEmpty() && !pendingEnvelopes.containsKey(scope.storageKey)) { scheduleWrite(scope, remoteConfigs) } return QRemoteConfigList(remoteConfigs) @@ -197,11 +338,11 @@ internal class PersistentRemoteConfigCache( private fun scheduleWrite( scope: RemoteConfigCacheScope, remoteConfigs: List, - previousEnvelope: PersistentRemoteConfigEnvelope? = memoryEnvelopes[scope.storageKey], + completion: (Boolean) -> Unit = {}, + requireExactPersistence: Boolean = false, + isSuccessfulCommit: ((PersistentRemoteConfigEnvelope?) -> Boolean)? = null, ) { val storageKey = scope.storageKey - val revision = ++nextRevision - pendingRevisions[storageKey] = revision val envelope = remoteConfigs.takeIf { it.isNotEmpty() }?.let { PersistentRemoteConfigEnvelope( version = CACHE_VERSION, @@ -211,43 +352,114 @@ internal class PersistentRemoteConfigCache( remoteConfigs = it, ) } - if (envelope == null) { - memoryEnvelopes.remove(storageKey) - } else { - memoryEnvelopes[storageKey] = envelope + // Admission must finish before publishing a new pending revision: otherwise an + // unpersistable newer mutation can cancel an already accepted write. This bounded + // serialization runs on the caller; production evaluates at most 64 entries and + // admits at most 512 KiB (an oversized entry is serialized once to reject it), while + // the durable SharedPreferences commit remains on persistenceExecutor. + val persistencePayload = preparePersistencePayload(envelope, requireExactPersistence) + if (persistencePayload == null) { + completion(false) + return } - persistenceExecutor.execute { - persistLatest(storageKey, revision, envelope, previousEnvelope) + + val previousPendingState = PendingState( + revision = pendingRevisions[storageKey], + hasEnvelope = pendingEnvelopes.containsKey(storageKey), + envelope = pendingEnvelopes[storageKey], + completions = pendingCompletions[storageKey], + ) + val revision = ++nextRevision + pendingRevisions[storageKey] = revision + // Subsequent coalesced mutations must build from what can actually become durable, + // not from entries removed by byte-bound admission. + pendingEnvelopes[storageKey] = persistencePayload.envelope + pendingCompletions[storageKey] = previousPendingState.completions.orEmpty().toMutableList().apply { + add(PendingCompletion(isSuccessfulCommit ?: { committed -> committed == envelope }, completion)) + } + try { + persistenceExecutor.execute { + persistLatest(storageKey, revision, persistencePayload) + } + } catch (_: RejectedExecutionException) { + if (pendingRevisions[storageKey] == revision) { + restorePendingState(storageKey, previousPendingState) + completion(false) + } } } private fun persistLatest( storageKey: String, revision: Long, - envelope: PersistentRemoteConfigEnvelope?, - previousEnvelope: PersistentRemoteConfigEnvelope?, + persistencePayload: PersistencePayload, ) { - val boundedEnvelopeAndJson = envelope?.let(::fitWithinByteLimit) - ?: envelope?.let { previousEnvelope?.let(::fitWithinByteLimit) } - synchronized(this) { - if (pendingRevisions[storageKey] != revision) return + val completionResults = commitLatestPersistence(storageKey, revision, persistencePayload) + completionResults.forEach { (completion, committed) -> completion(committed) } + } + + private fun restorePendingState(storageKey: String, previous: PendingState) { + previous.revision?.let { pendingRevisions[storageKey] = it } + ?: pendingRevisions.remove(storageKey) + if (previous.hasEnvelope) { + pendingEnvelopes[storageKey] = previous.envelope + } else { + pendingEnvelopes.remove(storageKey) + } + previous.completions?.let { pendingCompletions[storageKey] = it } + ?: pendingCompletions.remove(storageKey) + } + + private fun preparePersistencePayload( + envelope: PersistentRemoteConfigEnvelope?, + requireExactPersistence: Boolean, + ): PersistencePayload? = try { + if (envelope == null) { + PersistencePayload(null, null) + } else { + envelope.let(::fitWithinByteLimit) + ?.takeUnless { (boundedEnvelope) -> + requireExactPersistence && boundedEnvelope != envelope + } + ?.let { (boundedEnvelope, json) -> PersistencePayload(boundedEnvelope, json) } + } + } catch (_: Exception) { + null + } + + @Synchronized + private fun commitLatestPersistence( + storageKey: String, + revision: Long, + persistencePayload: PersistencePayload?, + ): List Unit, Boolean>> { + if (pendingRevisions[storageKey] != revision) return emptyList() + + val attempt = persistPayload(storageKey, persistencePayload) + if (attempt.committed) { + updateCommittedMemory(storageKey, persistencePayload, attempt.indexUpdate) + } + pendingRevisions.remove(storageKey) + pendingEnvelopes.remove(storageKey) + return pendingCompletions.remove(storageKey).orEmpty().map { pending -> + pending.callback to ( + attempt.committed && pending.isSuccessfulCommit(persistencePayload?.envelope) + ) + } + } - val boundedEnvelope = boundedEnvelopeAndJson?.first - val json = boundedEnvelopeAndJson?.second + private fun persistPayload( + storageKey: String, + persistencePayload: PersistencePayload?, + ): PersistenceAttempt { + if (persistencePayload == null) return PersistenceAttempt.failed() + + return try { + val json = persistencePayload.json val indexUpdate = createIndexUpdate( storageKey, json?.toByteArray(Charsets.UTF_8)?.size, ) - if (boundedEnvelope == null || json == null) { - memoryEnvelopes.remove(storageKey) - } else { - memoryEnvelopes[storageKey] = boundedEnvelope - } - indexUpdate.evictedStorageKeys.forEach { evictedStorageKey -> - if (!pendingRevisions.containsKey(evictedStorageKey)) { - memoryEnvelopes.remove(evictedStorageKey) - } - } val values = buildMap { json?.let { put(storageKey, it) } indexUpdate.index?.let { put(CACHE_INDEX_KEY, indexAdapter.toJson(it)) } @@ -257,13 +469,36 @@ internal class PersistentRemoteConfigCache( addAll(indexUpdate.evictedStorageKeys) if (indexUpdate.index == null) add(CACHE_INDEX_KEY) } - values.keys - cache.updateStrings(values, removedKeys) - if (pendingRevisions[storageKey] == revision) { - pendingRevisions.remove(storageKey) + PersistenceAttempt(cache.updateStringsDurably(values, removedKeys), indexUpdate) + } catch (_: Exception) { + PersistenceAttempt.failed() + } + } + + private fun updateCommittedMemory( + storageKey: String, + persistencePayload: PersistencePayload?, + indexUpdate: PersistentRemoteConfigIndexUpdate?, + ) { + persistencePayload?.envelope?.let { memoryEnvelopes[storageKey] = it } + ?: memoryEnvelopes.remove(storageKey) + indexUpdate?.evictedStorageKeys.orEmpty().forEach { evictedStorageKey -> + if (!pendingRevisions.containsKey(evictedStorageKey)) { + memoryEnvelopes.remove(evictedStorageKey) } } } + @Synchronized + private fun loadLatestEnvelope(scope: RemoteConfigCacheScope): PersistentRemoteConfigEnvelope? { + val storageKey = scope.storageKey + return if (pendingEnvelopes.containsKey(storageKey)) { + pendingEnvelopes[storageKey] + } else { + loadEnvelope(scope) + } + } + private fun fitWithinByteLimit( original: PersistentRemoteConfigEnvelope, ): Pair? { @@ -326,10 +561,7 @@ internal class PersistentRemoteConfigCache( } else { null } - return index?.takeIf { it.isValid() } ?: run { - cache.remove(CACHE_INDEX_KEY) - emptyIndex() - } + return index?.takeIf { it.isValid() } ?: emptyIndex() } private fun PersistentRemoteConfigIndex.isValid(): Boolean { @@ -407,6 +639,18 @@ internal class PersistentRemoteConfigCache( return contextKeys.size == contextKeys.distinct().size } + private fun List.areValidForRequestedPersistence( + normalizedRequestedKeys: Set, + ): Boolean = if (any { !it.isCorrect }) { + false + } else { + val returnedKeys = map { config -> + config.source.contextKey.normalizedRemoteConfigContextKey() + } + returnedKeys.size == returnedKeys.distinct().size && + returnedKeys.all { it in normalizedRequestedKeys } + } + private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size override fun currentScope(): RemoteConfigCacheScope? { @@ -475,3 +719,29 @@ private data class PersistentRemoteConfigIndexUpdate( val index: PersistentRemoteConfigIndex?, val evictedStorageKeys: Set, ) + +private data class PersistencePayload( + val envelope: PersistentRemoteConfigEnvelope?, + val json: String?, +) + +private data class PendingCompletion( + val isSuccessfulCommit: (PersistentRemoteConfigEnvelope?) -> Boolean, + val callback: (Boolean) -> Unit, +) + +private data class PendingState( + val revision: Long?, + val hasEnvelope: Boolean, + val envelope: PersistentRemoteConfigEnvelope?, + val completions: MutableList?, +) + +private data class PersistenceAttempt( + val committed: Boolean, + val indexUpdate: PersistentRemoteConfigIndexUpdate?, +) { + companion object { + fun failed() = PersistenceAttempt(false, null) + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt index 4319d6dc..1141196d 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCache.kt @@ -38,6 +38,38 @@ internal class SharedPreferencesCache( }.apply() } + @Suppress("TooGenericExceptionCaught") // Any runtime commit failure needs the same in-memory rollback. + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + val affectedKeys = values.keys + removedKeys + val previousValues = affectedKeys.associateWith { key -> + val exists = preferences.contains(key) + exists to if (exists) preferences.getString(key, null) else null + } + val committed = try { + preferences.edit().also { editor -> + removedKeys.forEach { key -> editor.remove(key) } + values.forEach { (key, value) -> editor.putString(key, value) } + }.commit() + } catch (error: RuntimeException) { + restoreStrings(previousValues) + throw error + } + if (!committed) restoreStrings(previousValues) + return committed + } + + private fun restoreStrings(previousValues: Map>) { + preferences.edit().also { editor -> + previousValues.forEach { (key, previous) -> + if (previous.first) { + editor.putString(key, previous.second) + } else { + editor.remove(key) + } + } + }.apply() + } + override fun getString(key: String, defValue: String?): String? = preferences.getString(key, defValue) diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt index 7ccdaea2..653aa38d 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QRemoteConfigManagerTest.kt @@ -76,6 +76,166 @@ internal class QRemoteConfigManagerTest { verify(exactly = 1) { callback.onSuccess(serverConfig) } } + @Test + fun `single network success waits until its last known good is durably committed`() { + userStateProvider.stable = true + persistentCache.deferDurableMutations = true + val serverConfig = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onSuccess(serverConfig) + + verify { callback wasNot Called } + assertEquals(null, persistentCache.get("ctx")) + + persistentCache.completeNextDurableMutation(success = true) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(serverConfig, persistentCache.get("ctx")) + verify(exactly = 1) { callback.onSuccess(serverConfig) } + } + + @Test + fun `failed single persistence serves the prior committed last known good instead of fresh data`() { + userStateProvider.stable = true + val previous = remoteConfigFor("ctx") + val fresh = remoteConfigFor("ctx") + persistentCache.save(previous) + persistentCache.deferDurableMutations = true + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onSuccess(fresh) + persistentCache.completeNextDurableMutation(success = false) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(previous, persistentCache.get("ctx")) + verify(exactly = 1) { callback.onSuccess(previous) } + verify(exactly = 0) { callback.onSuccess(fresh) } + verify(exactly = 0) { callback.onError(any()) } + } + + @Test + fun `failed single persistence without fallback reports an error instead of fresh unsaved data`() { + userStateProvider.stable = true + persistentCache.deferDurableMutations = true + val fresh = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { mockFallbacksService.obtainFallbackData() } returns null + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onSuccess(fresh) + persistentCache.completeNextDurableMutation(success = false) + shadowOf(Looper.getMainLooper()).idle() + + verify(exactly = 0) { callback.onSuccess(any()) } + verify(exactly = 1) { + callback.onError(match { it.code == QonversionErrorCode.ResponseParsingFailed }) + } + } + + @Test + fun `invalidation while persistence is pending reissues and never delivers the superseded response`() { + userStateProvider.stable = true + persistentCache.deferDurableMutations = true + val superseded = remoteConfigFor("ctx") + val fresh = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallbacks.first().onSuccess(superseded) + manager.invalidateRemoteConfigsCache() + persistentCache.completeNextDurableMutation(success = true) + shadowOf(Looper.getMainLooper()).idle() + + verify(exactly = 0) { callback.onSuccess(any()) } + assertEquals(2, serviceCallbacks.size) + + serviceCallbacks.last().onSuccess(fresh) + persistentCache.completeNextDurableMutation(success = true) + shadowOf(Looper.getMainLooper()).idle() + + verify(exactly = 1) { callback.onSuccess(fresh) } + verify(exactly = 0) { callback.onSuccess(superseded) } + } + + @Test + fun `list network success waits for atomic durable reconciliation`() { + userStateProvider.stable = true + persistentCache.deferDurableMutations = true + val fresh = remoteConfigFor("ctx") + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallback)) + } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfigList(listOf("ctx"), false, callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(fresh))) + + verify { callback wasNot Called } + persistentCache.completeNextDurableMutation(success = true) + shadowOf(Looper.getMainLooper()).idle() + + verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(fresh) }) } + } + + @Test + fun `failed list reconciliation preserves and serves the prior atomic snapshot`() { + userStateProvider.stable = true + val previous = remoteConfigFor("ctx") + val fresh = remoteConfigFor("ctx") + persistentCache.save(previous) + persistentCache.deferDurableMutations = true + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { + mockRemoteConfigService.loadRemoteConfigs(listOf("ctx"), false, capture(serviceCallback)) + } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfigList(listOf("ctx"), false, callback) + shadowOf(Looper.getMainLooper()).idle() + serviceCallback.captured.onSuccess(QRemoteConfigList(listOf(fresh))) + persistentCache.completeNextDurableMutation(success = false) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf(previous), persistentCache.getAll().remoteConfigs) + verify(exactly = 1) { callback.onSuccess(match { it.remoteConfigs == listOf(previous) }) } + verify(exactly = 0) { callback.onSuccess(match { fresh in it.remoteConfigs }) } + } + @Test fun `empty single context is canonicalized to the null context`() { userStateProvider.stable = true @@ -201,6 +361,63 @@ internal class QRemoteConfigManagerTest { verify(exactly = 0) { callback.onSuccess(stale) } } + @Test + fun `failed authoritative removal preserves prior LKG and reports persistence failure`() { + userStateProvider.stable = true + val stale = remoteConfigFor("ctx") + persistentCache.save(stale) + persistentCache.deferDurableMutations = true + val callback = mockk(relaxed = true) + val serviceCallback = slot() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallback)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + val noConfig = QonversionError(QonversionErrorCode.RemoteConfigurationNotAvailable) + serviceCallback.captured.onError(noConfig) + + verify { callback wasNot Called } + assertEquals(stale, persistentCache.get("ctx")) + + persistentCache.completeNextDurableMutation(success = false) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(stale, persistentCache.get("ctx")) + verify(exactly = 0) { callback.onError(noConfig) } + verify(exactly = 1) { + callback.onError(match { it.code == QonversionErrorCode.ResponseParsingFailed }) + } + verify(exactly = 0) { callback.onSuccess(any()) } + } + + @Test + fun `invalidation before authoritative no-config response fences the stale removal`() { + userStateProvider.stable = true + val lastKnownGood = remoteConfigFor("ctx") + persistentCache.save(lastKnownGood) + val callback = mockk(relaxed = true) + val serviceCallbacks = mutableListOf() + every { mockRemoteConfigService.loadRemoteConfig("ctx", capture(serviceCallbacks)) } just runs + every { mockUserPropertiesManager.forceSendProperties(any()) } answers { + firstArg()?.onComplete() + } + + manager.loadRemoteConfig("ctx", callback) + shadowOf(Looper.getMainLooper()).idle() + manager.invalidateRemoteConfigsCache() + serviceCallbacks.first().onError( + QonversionError(QonversionErrorCode.RemoteConfigurationNotAvailable), + ) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(lastKnownGood, persistentCache.get("ctx")) + assertEquals(2, serviceCallbacks.size) + verify { callback wasNot Called } + } + @Test fun `bundled fallback is never persisted as last known good`() { userStateProvider.stable = true @@ -2108,10 +2325,17 @@ internal class QRemoteConfigManagerTest { } private class FakeRemoteConfigCache : RemoteConfigCache { + private data class PendingMutation( + val apply: () -> Unit, + val completion: (Boolean) -> Unit, + ) + val savedConfigs = mutableListOf() var scope = RemoteConfigCacheScope("project", "Production", "user-a") val savedScopes = mutableListOf() var mutationCount = 0 + var deferDurableMutations = false + private val pendingMutations = ArrayDeque() private val scopedConfigs = linkedMapOf>() override fun currentScope(): RemoteConfigCacheScope = scope @@ -2127,6 +2351,12 @@ internal class QRemoteConfigManagerTest { scopedConfigs.getOrPut(scope, ::linkedMapOf)[remoteConfig.source.contextKey] = remoteConfig } + override fun save( + scope: RemoteConfigCacheScope, + remoteConfig: QRemoteConfig, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation({ save(scope, remoteConfig) }, completion) + override fun remove(contextKey: String?) { remove(scope, contextKey) } @@ -2137,6 +2367,12 @@ internal class QRemoteConfigManagerTest { savedConfigs.removeAll { it.source.contextKey == contextKey } } + override fun remove( + scope: RemoteConfigCacheScope, + contextKey: String?, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation({ remove(scope, contextKey) }, completion) + override fun replaceAll(remoteConfigs: List) { replaceAll(scope, remoteConfigs) } @@ -2152,6 +2388,12 @@ internal class QRemoteConfigManagerTest { } } + override fun replaceAll( + scope: RemoteConfigCacheScope, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation({ replaceAll(scope, remoteConfigs) }, completion) + override fun replaceRequested( requestedContextKeys: Set, remoteConfigs: List, @@ -2177,6 +2419,31 @@ internal class QRemoteConfigManagerTest { } } + override fun replaceRequested( + scope: RemoteConfigCacheScope, + requestedContextKeys: Set, + remoteConfigs: List, + completion: (Boolean) -> Unit, + ) = enqueueDurableMutation( + { replaceRequested(scope, requestedContextKeys, remoteConfigs) }, + completion, + ) + + fun completeNextDurableMutation(success: Boolean) { + val mutation = pendingMutations.removeFirst() + if (success) mutation.apply() + mutation.completion(success) + } + + private fun enqueueDurableMutation(apply: () -> Unit, completion: (Boolean) -> Unit) { + if (deferDurableMutations) { + pendingMutations.addLast(PendingMutation(apply, completion)) + } else { + apply() + completion(true) + } + } + override fun get(contextKey: String?): QRemoteConfig? = get(scope, contextKey) override fun get(scope: RemoteConfigCacheScope, contextKey: String?): QRemoteConfig? = diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt index a0a47556..baa35b0e 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PersistentRemoteConfigCacheTest.kt @@ -24,6 +24,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import java.lang.reflect.Type import java.util.concurrent.Executor +import java.util.concurrent.RejectedExecutionException internal class PersistentRemoteConfigCacheTest { private val backingCache = InMemoryCache() @@ -599,19 +600,628 @@ internal class PersistentRemoteConfigCacheTest { } @Test - fun `serialization is queued off caller thread while memory value is immediately available`() { + fun `durable save completion waits for the off-thread commit`() { val queuedExecutor = ManualExecutor() val asyncCache = cache(config, executor = queuedExecutor) val expected = remoteConfig(contextKey = "paywall", payloadValue = "v1") + var committed: Boolean? = null - asyncCache.save(expected) + asyncCache.save(requireNotNull(asyncCache.currentScope()), expected) { result -> + committed = result + } assertTrue(backingCache.strings.isEmpty()) - assertEquals(expected, asyncCache.get("paywall")) + assertNull(committed) + assertNull(asyncCache.get("paywall")) queuedExecutor.runAll() + assertEquals(true, committed) assertEquals(expected, cache(config).get("paywall")) } + @Test + fun `failed durable save preserves the prior committed last known good`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + val persistent = cache(config) + persistent.save(previous) + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.save( + requireNotNull(persistent.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(previous, persistent.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `failed first durable save never exposes the fresh value from memory or disk`() { + val persistent = cache(config) + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.save( + requireNotNull(persistent.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertNull(persistent.get("paywall")) + assertNull(cache(config).get("paywall")) + assertTrue(backingCache.strings.isEmpty()) + } + + @Test + fun `exception during durable save preserves the prior committed last known good`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + val persistent = cache(config) + persistent.save(previous) + backingCache.throwOnNextDurableUpdate = true + var committed: Boolean? = null + + persistent.save( + requireNotNull(persistent.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(previous, persistent.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `rejected persistence scheduling fails completion without replacing prior LKG`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + cache(config).save(previous) + val rejectingCache = cache( + config, + executor = Executor { throw RejectedExecutionException("shutting down") }, + ) + var committed: Boolean? = null + + rejectingCache.save( + requireNotNull(rejectingCache.currentScope()), + remoteConfig(contextKey = "paywall", payloadValue = "fresh"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(previous, rejectingCache.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `failed durable removal preserves payload and index for restart`() { + val previous = remoteConfig(contextKey = "paywall", payloadValue = "previous") + val persistent = cache(config) + persistent.save(previous) + val priorStrings = backingCache.strings.toMap() + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.remove(requireNotNull(persistent.currentScope()), "paywall") { result -> + committed = result + } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(previous, persistent.get("paywall")) + assertEquals(previous, cache(config).get("paywall")) + } + + @Test + fun `failed durable list reconciliation preserves the whole prior snapshot`() { + val first = remoteConfig(contextKey = "first", payloadValue = "old-first") + val second = remoteConfig(contextKey = "second", payloadValue = "old-second") + val persistent = cache(config) + persistent.replaceAll(listOf(first, second)) + val priorStrings = backingCache.strings.toMap() + backingCache.nextDurableUpdateResult = false + var committed: Boolean? = null + + persistent.replaceRequested( + requireNotNull(persistent.currentScope()), + requestedContextKeys = setOf("first", "second"), + remoteConfigs = listOf(remoteConfig("first", "fresh-first")), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(listOf(first, second), persistent.getAll().remoteConfigs) + assertEquals(listOf(first, second), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced durable saves succeed when the latest snapshot contains both requested values`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val first = remoteConfig(contextKey = "first", payloadValue = "first") + val second = remoteConfig(contextKey = "second", payloadValue = "second") + val completions = mutableListOf>() + + persistent.save(scope, first) { completions += "first" to it } + persistent.save(scope, second) { completions += "second" to it } + + assertTrue(completions.isEmpty()) + queuedExecutor.runNext() + assertTrue(completions.isEmpty()) + queuedExecutor.runNext() + + assertEquals(listOf("first" to true, "second" to true), completions) + assertEquals(listOf(first, second), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced durable save reports a superseded value for the same context as uncommitted`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val first = remoteConfig(contextKey = "shared", payloadValue = "first") + val second = remoteConfig(contextKey = "shared", payloadValue = "second") + val completions = mutableListOf>() + + persistent.save(scope, first) { completions += "first" to it } + persistent.save(scope, second) { completions += "second" to it } + + queuedExecutor.runAll() + + assertEquals(listOf("first" to false, "second" to true), completions) + assertEquals(listOf(second), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced removal and unrelated save both succeed when the target stays absent`() { + val target = remoteConfig(contextKey = "target", payloadValue = "old") + cache(config).save(target) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val unrelated = remoteConfig(contextKey = "unrelated", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.remove(scope, "target") { completions += "remove" to it } + persistent.save(scope, unrelated) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("remove" to true, "save" to true), completions) + assertEquals(listOf(unrelated), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced removal fails when a later save re-adds the same target`() { + cache(config).save(remoteConfig(contextKey = "target", payloadValue = "old")) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val replacement = remoteConfig(contextKey = "target", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.remove(scope, "target") { completions += "remove" to it } + persistent.save(scope, replacement) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("remove" to false, "save" to true), completions) + assertEquals(listOf(replacement), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested replacement and unrelated save both succeed`() { + val oldRequested = remoteConfig(contextKey = "requested", payloadValue = "old") + cache(config).save(oldRequested) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val replacement = remoteConfig(contextKey = "requested", payloadValue = "fresh") + val unrelated = remoteConfig(contextKey = "unrelated", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), listOf(replacement)) { + completions += "replace" to it + } + persistent.save(scope, unrelated) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to true, "save" to true), completions) + assertEquals(listOf(replacement, unrelated), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested replacement fails when a later save overwrites its target`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val replacement = remoteConfig(contextKey = "requested", payloadValue = "first") + val overwrite = remoteConfig(contextKey = "requested", payloadValue = "second") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), listOf(replacement)) { + completions += "replace" to it + } + persistent.save(scope, overwrite) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to false, "save" to true), completions) + assertEquals(listOf(overwrite), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested omission fails when a later save re-adds the omitted target`() { + cache(config).save(remoteConfig(contextKey = "requested", payloadValue = "old")) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val readded = remoteConfig(contextKey = "requested", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), emptyList()) { + completions += "replace" to it + } + persistent.save(scope, readded) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to false, "save" to true), completions) + assertEquals(listOf(readded), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced requested omission and unrelated save both succeed while the omitted target stays absent`() { + cache(config).save(remoteConfig(contextKey = "requested", payloadValue = "old")) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val unrelated = remoteConfig(contextKey = "unrelated", payloadValue = "fresh") + val completions = mutableListOf>() + + persistent.replaceRequested(scope, setOf("requested"), emptyList()) { + completions += "replace" to it + } + persistent.save(scope, unrelated) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replace" to true, "save" to true), completions) + assertEquals(listOf(unrelated), cache(config).getAll().remoteConfigs) + } + + @Test + fun `coalesced replace all reports false when a later mutation changes its whole snapshot`() { + val queuedExecutor = ManualExecutor() + val persistent = cache(config, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val authoritative = remoteConfig(contextKey = "requested", payloadValue = "fresh") + val later = remoteConfig(contextKey = "unrelated", payloadValue = "later") + val completions = mutableListOf>() + + persistent.replaceAll(scope, listOf(authoritative)) { completions += "replaceAll" to it } + persistent.save(scope, later) { completions += "save" to it } + queuedExecutor.runAll() + + assertEquals(listOf("replaceAll" to false, "save" to true), completions) + assertEquals(listOf(authoritative, later), cache(config).getAll().remoteConfigs) + } + + @Test + fun `oversized newer single save fails only itself and does not cancel an accepted pending save`() { + val fitting = remoteConfig(contextKey = "fitting", payloadValue = "small") + cache(config).save(fitting) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val queuedExecutor = ManualExecutor() + val persistent = cache( + config, + limits = RemoteConfigCacheLimits(2, 4, oneEnvelopeBytes + 16), + executor = queuedExecutor, + ) + val oversized = remoteConfig("oversized", "x".repeat(oneEnvelopeBytes)) + val completions = mutableListOf>() + + persistent.save(requireNotNull(persistent.currentScope()), fitting) { + completions += "fitting" to it + } + persistent.save(requireNotNull(persistent.currentScope()), oversized) { + completions += "oversized" to it + } + + assertEquals(listOf("oversized" to false), completions) + queuedExecutor.runAll() + assertEquals(listOf("oversized" to false, "fitting" to true), completions) + assertEquals(listOf(fitting), cache(config).getAll().remoteConfigs) + } + + @Test + fun `oversized newer strict replacement fails only itself and preserves an accepted pending replacement`() { + val fitting = remoteConfig(contextKey = "fitting", payloadValue = "small") + cache(config).save(fitting) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val queuedExecutor = ManualExecutor() + val persistent = cache( + config, + limits = RemoteConfigCacheLimits(2, 4, oneEnvelopeBytes + 16), + executor = queuedExecutor, + ) + val oversized = remoteConfig("oversized", "x".repeat(oneEnvelopeBytes)) + val completions = mutableListOf>() + val scope = requireNotNull(persistent.currentScope()) + + persistent.replaceAll(scope, listOf(fitting)) { completions += "fitting" to it } + persistent.replaceAll(scope, listOf(oversized)) { completions += "oversized" to it } + + assertEquals(listOf("oversized" to false), completions) + queuedExecutor.runAll() + assertEquals(listOf("oversized" to false, "fitting" to true), completions) + assertEquals(listOf(fitting), cache(config).getAll().remoteConfigs) + } + + @Test + fun `rejected newer save restores an accepted pending save and completes each exactly once`() { + val executor = AcceptFirstRejectSecondExecutor() + val persistent = cache(config, executor = executor) + val scope = requireNotNull(persistent.currentScope()) + val first = remoteConfig(contextKey = "first", payloadValue = "first") + val second = remoteConfig(contextKey = "second", payloadValue = "second") + val completions = mutableListOf>() + + persistent.save(scope, first) { completions += "first" to it } + persistent.save(scope, second) { completions += "second" to it } + + assertEquals(listOf("second" to false), completions) + executor.runAccepted() + assertEquals(listOf("second" to false, "first" to true), completions) + assertEquals(listOf(first), cache(config).getAll().remoteConfigs) + } + + @Test + fun `executor that runs then rejects cannot complete the same durable save twice`() { + val persistent = cache(config, executor = RunThenRejectExecutor()) + val expected = remoteConfig(contextKey = "context", payloadValue = "value") + val completions = mutableListOf() + + persistent.save(requireNotNull(persistent.currentScope()), expected) { + completions += it + } + + assertEquals(listOf(true), completions) + assertEquals(expected, cache(config).get("context")) + } + + @Test + fun `strict durable reconciliation never commits a bounded partial snapshot`() { + val second = remoteConfig(contextKey = "second", payloadValue = "second-${"x".repeat(1_000)}") + cache(config).save(second) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val strictCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = oneEnvelopeBytes + 16, + ), + ) + val first = remoteConfig(contextKey = "first", payloadValue = "first-${"x".repeat(1_000)}") + var committed: Boolean? = null + + strictCache.replaceAll( + requireNotNull(strictCache.currentScope()), + listOf(first, second), + ) { result -> committed = result } + + assertEquals(false, committed) + assertTrue(backingCache.strings.isEmpty()) + assertTrue(strictCache.getAll().remoteConfigs.isEmpty()) + } + + @Test + fun `durable single save may evict old contexts when the requested value is fully committed`() { + val old = remoteConfig(contextKey = "old", payloadValue = "old-${"x".repeat(1_000)}") + cache(config).save(old) + val oneEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val boundedCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = oneEnvelopeBytes + 16, + ), + ) + boundedCache.save(old) + val fresh = remoteConfig(contextKey = "fresh", payloadValue = "fresh-${"x".repeat(1_000)}") + var committed: Boolean? = null + + boundedCache.save(requireNotNull(boundedCache.currentScope()), fresh) { result -> + committed = result + } + + assertEquals(true, committed) + assertEquals(listOf(fresh), boundedCache.getAll().remoteConfigs) + assertEquals(listOf(fresh), cache(config).getAll().remoteConfigs) + } + + @Test + fun `strict operation after a bounded pending save builds from the admitted snapshot`() { + val oldFirst = remoteConfig(contextKey = "old-first", payloadValue = "first-${"x".repeat(1_000)}") + val oldSecond = remoteConfig(contextKey = "old-second", payloadValue = "second-${"x".repeat(1_000)}") + cache(config).replaceAll(listOf(oldFirst, oldSecond)) + val twoEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = twoEnvelopeBytes + 16, + ) + cache(config, limits = limits).replaceAll(listOf(oldFirst, oldSecond)) + val queuedExecutor = ManualExecutor() + val persistent = cache(config, limits = limits, executor = queuedExecutor) + val scope = requireNotNull(persistent.currentScope()) + val added = remoteConfig(contextKey = "added", payloadValue = "added-${"x".repeat(1_000)}") + val updatedSecond = remoteConfig( + contextKey = "old-second", + payloadValue = "updated-${"x".repeat(1_000)}", + ) + val completions = mutableListOf>() + + persistent.save(scope, added) { completions += "save" to it } + persistent.replaceRequested(scope, setOf("old-second"), listOf(updatedSecond)) { + completions += "replace" to it + } + + assertTrue(completions.isEmpty()) + queuedExecutor.runAll() + assertEquals(listOf("save" to true, "replace" to true), completions) + assertEquals(listOf(added, updatedSecond), cache(config).getAll().remoteConfigs) + } + + @Test + fun `rejection restores the admitted bounded snapshot for the next strict operation`() { + val oldFirst = remoteConfig(contextKey = "old-first", payloadValue = "first-${"x".repeat(1_000)}") + val oldSecond = remoteConfig(contextKey = "old-second", payloadValue = "second-${"x".repeat(1_000)}") + cache(config).replaceAll(listOf(oldFirst, oldSecond)) + val twoEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + backingCache.strings.clear() + backingCache.batchUpdates.clear() + val limits = RemoteConfigCacheLimits(2, 4, twoEnvelopeBytes + 16) + cache(config, limits = limits).replaceAll(listOf(oldFirst, oldSecond)) + val executor = ScriptedExecutor(acceptance = listOf(true, false, true)) + val persistent = cache(config, limits = limits, executor = executor) + val scope = requireNotNull(persistent.currentScope()) + val added = remoteConfig(contextKey = "added", payloadValue = "added-${"x".repeat(1_000)}") + val rejected = remoteConfig( + contextKey = "rejected", + payloadValue = "rejected-${"x".repeat(1_000)}", + ) + val updatedSecond = remoteConfig( + contextKey = "old-second", + payloadValue = "updated-${"x".repeat(1_000)}", + ) + val completions = mutableListOf>() + + persistent.save(scope, added) { completions += "save" to it } + persistent.save(scope, rejected) { completions += "rejected" to it } + persistent.replaceRequested(scope, setOf("old-second"), listOf(updatedSecond)) { + completions += "replace" to it + } + + assertEquals(listOf("rejected" to false), completions) + executor.runAccepted() + assertEquals( + listOf("rejected" to false, "save" to true, "replace" to true), + completions, + ) + assertEquals(listOf(added, updatedSecond), cache(config).getAll().remoteConfigs) + } + + @Test + fun `durable single save rejects an individually oversized requested value without changing storage`() { + val previous = remoteConfig(contextKey = "old", payloadValue = "small") + cache(config).save(previous) + val priorEnvelopeBytes = persistedEnvelopeJson(persistedEnvelopeKey()).utf8Size() + val priorStrings = backingCache.strings.toMap() + val boundedCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 4, + maxTotalBytes = priorEnvelopeBytes + 16, + ), + ) + val oversized = remoteConfig( + contextKey = "fresh", + payloadValue = "oversized-${"x".repeat(priorEnvelopeBytes)}", + ) + var committed: Boolean? = null + + boundedCache.save(requireNotNull(boundedCache.currentScope()), oversized) { result -> + committed = result + } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(listOf(previous), boundedCache.getAll().remoteConfigs) + assertEquals(listOf(previous), cache(config).getAll().remoteConfigs) + } + + @Test + fun `strict durable replace all rejects entry truncation without changing storage`() { + val strictCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 1, + maxTotalBytes = 10_000, + ), + ) + var committed: Boolean? = null + + strictCache.replaceAll( + requireNotNull(strictCache.currentScope()), + listOf(remoteConfig("first", "first"), remoteConfig("second", "second")), + ) { result -> committed = result } + + assertEquals(false, committed) + assertTrue(backingCache.strings.isEmpty()) + assertTrue(strictCache.getAll().remoteConfigs.isEmpty()) + } + + @Test + fun `strict durable requested reconciliation rejects entry truncation and preserves prior snapshot`() { + val first = remoteConfig("first", "first") + val strictCache = cache( + config, + limits = RemoteConfigCacheLimits( + maxScopes = 2, + maxEntriesPerScope = 1, + maxTotalBytes = 10_000, + ), + ) + strictCache.save(first) + val priorStrings = backingCache.strings.toMap() + var committed: Boolean? = null + + strictCache.replaceRequested( + requireNotNull(strictCache.currentScope()), + requestedContextKeys = setOf("second"), + remoteConfigs = listOf(remoteConfig("second", "second")), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + assertEquals(listOf(first), strictCache.getAll().remoteConfigs) + } + + @Test + fun `failed scope eviction keeps payload and index on the prior atomic snapshot`() { + val limited = cache( + config, + limits = RemoteConfigCacheLimits(maxScopes = 1, maxEntriesPerScope = 4, maxTotalBytes = 10_000), + ) + val first = remoteConfig(contextKey = "ctx", payloadValue = "first") + limited.save(first) + val priorStrings = backingCache.strings.toMap() + backingCache.nextDurableUpdateResult = false + config.uid = "user-b" + var committed: Boolean? = null + + limited.save( + requireNotNull(limited.currentScope()), + remoteConfig(contextKey = "ctx", payloadValue = "second"), + ) { result -> committed = result } + + assertEquals(false, committed) + assertEquals(priorStrings, backingCache.strings) + config.uid = "user-a" + assertEquals(first, limited.get("ctx")) + config.uid = "user-b" + assertNull(limited.get("ctx")) + } + private fun cache( internalConfig: InternalConfig, limits: RemoteConfigCacheLimits = RemoteConfigCacheLimits(), @@ -691,6 +1301,8 @@ internal class PersistentRemoteConfigCacheTest { val strings = mutableMapOf() val batchUpdates = mutableListOf() + var nextDurableUpdateResult = true + var throwOnNextDurableUpdate = false private val values = mutableMapOf() override fun putInt(key: String, value: Int) { values[key] = value } @@ -707,6 +1319,16 @@ internal class PersistentRemoteConfigCacheTest { removedKeys.forEach(strings::remove) strings.putAll(values) } + override fun updateStringsDurably(values: Map, removedKeys: Set): Boolean { + if (throwOnNextDurableUpdate) { + throwOnNextDurableUpdate = false + throw IllegalStateException("simulated storage failure") + } + val result = nextDurableUpdateResult + nextDurableUpdateResult = true + if (result) updateStrings(values, removedKeys) + return result + } override fun getString(key: String, defValue: String?) = strings[key] ?: defValue override fun putObject(key: String, value: T, adapter: JsonAdapter) { putString(key, adapter.toJson(value)) @@ -731,6 +1353,55 @@ internal class PersistentRemoteConfigCacheTest { tasks.removeFirst().run() } } + + fun runNext() { + tasks.removeFirst().run() + } + } + + private class AcceptFirstRejectSecondExecutor : Executor { + private var accepted: Runnable? = null + private var invocationCount = 0 + + override fun execute(command: Runnable) { + invocationCount += 1 + if (invocationCount == 1) { + accepted = command + } else { + throw RejectedExecutionException("simulated rejection") + } + } + + fun runAccepted() { + requireNotNull(accepted).run() + accepted = null + } + } + + private class RunThenRejectExecutor : Executor { + override fun execute(command: Runnable) { + command.run() + throw RejectedExecutionException("simulated rejection after execution") + } + } + + private class ScriptedExecutor(private val acceptance: List) : Executor { + private val accepted = ArrayDeque() + private var invocationCount = 0 + + override fun execute(command: Runnable) { + val accepts = acceptance.getOrElse(invocationCount) { false } + invocationCount += 1 + if (accepts) { + accepted.addLast(command) + } else { + throw RejectedExecutionException("simulated rejection") + } + } + + fun runAccepted() { + while (accepted.isNotEmpty()) accepted.removeFirst().run() + } } private class CountingEnvelopeAdapterFactory : JsonAdapter.Factory { diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheDurabilityTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheDurabilityTest.kt new file mode 100644 index 00000000..65ef2e8c --- /dev/null +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheDurabilityTest.kt @@ -0,0 +1,89 @@ +package com.qonversion.android.sdk.internal.storage + +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class SharedPreferencesCacheDurabilityTest { + private val preferences = mockk() + private val editor = mockk() + private val cache = SharedPreferencesCache(preferences) + + @Test + fun `batch string update is durably committed as one transaction`() { + every { preferences.contains(any()) } returns false + every { preferences.edit() } returns editor + every { editor.remove("stale") } returns editor + every { editor.putString("current", "payload") } returns editor + every { editor.commit() } returns true + + val committed = cache.updateStringsDurably( + values = mapOf("current" to "payload"), + removedKeys = setOf("stale"), + ) + + assertTrue(committed) + verifyOrder { + editor.remove("stale") + editor.putString("current", "payload") + editor.commit() + } + verify(exactly = 0) { editor.apply() } + } + + @Test + fun `batch string update exposes a failed disk commit`() { + val rollbackEditor = mockk() + every { preferences.contains("current") } returns true + every { preferences.getString("current", null) } returns "previous" + every { preferences.edit() } returnsMany listOf(editor, rollbackEditor) + every { editor.putString("current", "payload") } returns editor + every { editor.commit() } returns false + every { rollbackEditor.putString("current", "previous") } returns rollbackEditor + every { rollbackEditor.apply() } returns Unit + + val committed = cache.updateStringsDurably( + values = mapOf("current" to "payload"), + removedKeys = emptySet(), + ) + + assertFalse(committed) + verifyOrder { + editor.putString("current", "payload") + editor.commit() + rollbackEditor.putString("current", "previous") + rollbackEditor.apply() + } + } + + @Test + fun `batch string update restores the prior in-process view when commit throws`() { + val rollbackEditor = mockk() + every { preferences.contains("current") } returns false + every { preferences.edit() } returnsMany listOf(editor, rollbackEditor) + every { editor.putString("current", "payload") } returns editor + every { editor.commit() } throws IllegalStateException("disk unavailable") + every { rollbackEditor.remove("current") } returns rollbackEditor + every { rollbackEditor.apply() } returns Unit + + assertThrows(IllegalStateException::class.java) { + cache.updateStringsDurably( + values = mapOf("current" to "payload"), + removedKeys = emptySet(), + ) + } + + verifyOrder { + editor.putString("current", "payload") + editor.commit() + rollbackEditor.remove("current") + rollbackEditor.apply() + } + } +}