diff --git a/ConfidenceDemoApp/build.gradle.kts b/ConfidenceDemoApp/build.gradle.kts index b7ca9f9b..06ce49fe 100644 --- a/ConfidenceDemoApp/build.gradle.kts +++ b/ConfidenceDemoApp/build.gradle.kts @@ -58,6 +58,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + signingConfig = signingConfigs.getByName("debug") } } compileOptions { diff --git a/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt b/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt index 3754e7c9..88f2b993 100644 --- a/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt +++ b/ConfidenceDemoApp/src/main/java/com/example/confidencedemoapp/MainVm.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dev.openfeature.contrib.providers.ConfidenceFeatureProvider +import dev.openfeature.contrib.providers.InitialisationStrategy import dev.openfeature.sdk.Client import dev.openfeature.sdk.EvaluationContext import dev.openfeature.sdk.FlagEvaluationDetails @@ -36,10 +37,18 @@ class MainVm(app: Application) : AndroidViewModel(app) { init { val start = System.currentTimeMillis() val clientSecret = ClientSecretProvider.clientSecret() + + val strategy = if(ConfidenceFeatureProvider.isStorageEmpty(app.applicationContext)) { + InitialisationStrategy.FetchAndActivate + } else { + InitialisationStrategy.ActivateAndFetchAsync + } + OpenFeatureAPI.setProvider( ConfidenceFeatureProvider.create( app.applicationContext, - clientSecret + clientSecret, + initialisationStrategy = strategy ), initialContext = ctx ) diff --git a/Provider/src/main/java/dev/openfeature/contrib/providers/ConfidenceFeatureProvider.kt b/Provider/src/main/java/dev/openfeature/contrib/providers/ConfidenceFeatureProvider.kt index 2edc7430..d6d1bc63 100644 --- a/Provider/src/main/java/dev/openfeature/contrib/providers/ConfidenceFeatureProvider.kt +++ b/Provider/src/main/java/dev/openfeature/contrib/providers/ConfidenceFeatureProvider.kt @@ -3,6 +3,8 @@ package dev.openfeature.contrib.providers import android.content.Context import dev.openfeature.contrib.providers.apply.FlagApplier import dev.openfeature.contrib.providers.apply.FlagApplierWithRetries +import dev.openfeature.contrib.providers.cache.DiskStorage +import dev.openfeature.contrib.providers.cache.InMemoryCache import dev.openfeature.contrib.providers.cache.ProviderCache import dev.openfeature.contrib.providers.cache.ProviderCache.CacheResolveResult import dev.openfeature.contrib.providers.cache.StorageFileCache @@ -41,10 +43,12 @@ class ConfidenceFeatureProvider private constructor( override val hooks: List>, override val metadata: ProviderMetadata, private val cache: ProviderCache, + private val storage: DiskStorage, + private val initialisationStrategy: InitialisationStrategy, private val client: ConfidenceClient, private val flagApplier: FlagApplier, private val eventsPublisher: EventsPublisher, - private val dispatcher: CoroutineDispatcher + dispatcher: CoroutineDispatcher ) : FeatureProvider { private val job = SupervisorJob() private val coroutineScope = CoroutineScope(job + dispatcher) @@ -54,14 +58,43 @@ class ConfidenceFeatureProvider private constructor( eventsPublisher.publish(OpenFeatureEvents.ProviderReady) } } + override fun initialize(initialContext: EvaluationContext?) { if (initialContext == null) return + + // refresh cache with the last stored data + storage.read()?.let(cache::refresh) + + if (initialisationStrategy == InitialisationStrategy.ActivateAndFetchAsync) { + eventsPublisher.publish(OpenFeatureEvents.ProviderReady) + } + coroutineScope.launch(networkExceptionHandler) { val resolveResponse = client.resolve(listOf(), initialContext) if (resolveResponse is ResolveResponse.Resolved) { val (flags, resolveToken) = resolveResponse.flags - cache.refresh(flags.list, resolveToken, initialContext) - eventsPublisher.publish(OpenFeatureEvents.ProviderReady) + + // update the cache and emit the ready signal when the strategy is expected + // to wait for the network response + + // we store the flag anyways + val storedData = storage.store( + flags.list, + resolveToken, + initialContext + ) + + when (initialisationStrategy) { + InitialisationStrategy.FetchAndActivate -> { + // refresh the cache from the stored data + cache.refresh(data = storedData) + eventsPublisher.publish(OpenFeatureEvents.ProviderReady) + } + + InitialisationStrategy.ActivateAndFetchAsync -> { + // do nothing + } + } } } } @@ -184,15 +217,25 @@ class ConfidenceFeatureProvider private constructor( companion object { private class ConfidenceMetadata(override var name: String? = "confidence") : ProviderMetadata + fun isStorageEmpty( + context: Context + ): Boolean { + val storage = StorageFileCache.create(context) + val data = storage.read() + return data == null + } + @Suppress("LongParameterList") fun create( context: Context, clientSecret: String, region: ConfidenceRegion = ConfidenceRegion.EUROPE, + initialisationStrategy: InitialisationStrategy = InitialisationStrategy.FetchAndActivate, hooks: List> = listOf(), client: ConfidenceClient? = null, metadata: ProviderMetadata = ConfidenceMetadata(), cache: ProviderCache? = null, + storage: DiskStorage? = null, flagApplier: FlagApplier? = null, eventsPublisher: EventsPublisher = EventHandler.eventsPublisher(Dispatchers.IO), dispatcher: CoroutineDispatcher = Dispatchers.IO @@ -211,7 +254,9 @@ class ConfidenceFeatureProvider private constructor( return ConfidenceFeatureProvider( hooks = hooks, metadata = metadata, - cache = cache ?: StorageFileCache.create(context), + cache = cache ?: InMemoryCache(), + storage = storage ?: StorageFileCache.create(context), + initialisationStrategy = initialisationStrategy, client = configuredClient, flagApplier = flagApplierWithRetries, eventsPublisher, @@ -253,4 +298,9 @@ class ConfidenceFeatureProvider private constructor( ) } } +} + +sealed interface InitialisationStrategy { + object FetchAndActivate : InitialisationStrategy + object ActivateAndFetchAsync : InitialisationStrategy } \ No newline at end of file diff --git a/Provider/src/main/java/dev/openfeature/contrib/providers/cache/DiskStorage.kt b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/DiskStorage.kt new file mode 100644 index 00000000..8cb2fc8d --- /dev/null +++ b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/DiskStorage.kt @@ -0,0 +1,15 @@ +package dev.openfeature.contrib.providers.cache + +import dev.openfeature.contrib.providers.client.ResolvedFlag +import dev.openfeature.sdk.EvaluationContext + +interface DiskStorage { + fun store( + resolvedFlags: List, + resolveToken: String, + evaluationContext: EvaluationContext + ): CacheData + fun read(): CacheData? + + fun clear() +} \ No newline at end of file diff --git a/Provider/src/main/java/dev/openfeature/contrib/providers/cache/InMemoryCache.kt b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/InMemoryCache.kt index 93281690..14eb025c 100644 --- a/Provider/src/main/java/dev/openfeature/contrib/providers/cache/InMemoryCache.kt +++ b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/InMemoryCache.kt @@ -1,32 +1,14 @@ package dev.openfeature.contrib.providers.cache -import dev.openfeature.contrib.providers.cache.ProviderCache.CacheEntry import dev.openfeature.contrib.providers.cache.ProviderCache.CacheResolveEntry import dev.openfeature.contrib.providers.cache.ProviderCache.CacheResolveResult -import dev.openfeature.contrib.providers.client.ResolvedFlag import dev.openfeature.sdk.EvaluationContext -import dev.openfeature.sdk.Value -import kotlinx.serialization.Serializable open class InMemoryCache : ProviderCache { - var data: CacheData? = null + private var data: CacheData? = null - override fun refresh( - resolvedFlags: List, - resolveToken: String, - evaluationContext: EvaluationContext - ) { - data = CacheData( - values = resolvedFlags.associate { - it.flag to CacheEntry( - it.variant, - Value.Structure(it.value.asMap()), - it.reason - ) - }, - evaluationContextHash = evaluationContext.hashCode(), - resolveToken = resolveToken - ) + override fun refresh(cacheData: CacheData) { + data = cacheData } override fun resolve(flagName: String, ctx: EvaluationContext): CacheResolveResult { @@ -43,11 +25,4 @@ open class InMemoryCache : ProviderCache { override fun clear() { data = null } - - @Serializable - data class CacheData( - val resolveToken: String, - val evaluationContextHash: Int, - val values: Map - ) } \ No newline at end of file diff --git a/Provider/src/main/java/dev/openfeature/contrib/providers/cache/ProviderCache.kt b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/ProviderCache.kt index a5eb46e3..50d08405 100644 --- a/Provider/src/main/java/dev/openfeature/contrib/providers/cache/ProviderCache.kt +++ b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/ProviderCache.kt @@ -1,13 +1,12 @@ package dev.openfeature.contrib.providers.cache import dev.openfeature.contrib.providers.client.ResolveReason -import dev.openfeature.contrib.providers.client.ResolvedFlag import dev.openfeature.sdk.EvaluationContext import dev.openfeature.sdk.Value import kotlinx.serialization.Serializable interface ProviderCache { - fun refresh(resolvedFlags: List, resolveToken: String, evaluationContext: EvaluationContext) + fun refresh(data: CacheData) fun resolve(flagName: String, ctx: EvaluationContext): CacheResolveResult fun clear() diff --git a/Provider/src/main/java/dev/openfeature/contrib/providers/cache/StorageFileCache.kt b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/StorageFileCache.kt index c23e2d5f..18ceb9b2 100644 --- a/Provider/src/main/java/dev/openfeature/contrib/providers/cache/StorageFileCache.kt +++ b/Provider/src/main/java/dev/openfeature/contrib/providers/cache/StorageFileCache.kt @@ -3,48 +3,73 @@ package dev.openfeature.contrib.providers.cache import android.content.Context import dev.openfeature.contrib.providers.client.ResolvedFlag import dev.openfeature.sdk.EvaluationContext +import dev.openfeature.sdk.Value +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File const val FLAGS_FILE_NAME = "confidence_flags_cache.json" -class StorageFileCache private constructor(context: Context) : InMemoryCache() { +class StorageFileCache private constructor(context: Context) : DiskStorage { private val file: File = File(context.filesDir, FLAGS_FILE_NAME) - override fun refresh( + override fun store( resolvedFlags: List, resolveToken: String, evaluationContext: EvaluationContext - ) { - super.refresh(resolvedFlags, resolveToken, evaluationContext) - // TODO Should this happen before in-memory cache is changed? - writeToFile() + ): CacheData { + val data = toCacheData( + resolvedFlags, + resolveToken, + evaluationContext + ) + + write(Json.encodeToString(data)) + return data } override fun clear() { - super.clear() - // TODO Should this happen before in-memory cache is changed? file.delete() } - private fun writeToFile() { - val fileData = Json.encodeToString(data) - file.writeText(fileData) + private fun write(data: String) { + file.writeText(data) } - private fun readFile() { - if (!file.exists()) return + override fun read(): CacheData? { + if (!file.exists()) return null val fileText: String = file.bufferedReader().use { it.readText() } - if (fileText.isEmpty()) return - data = Json.decodeFromString(CacheData.serializer(), fileText) + if (fileText.isEmpty()) return null + return Json.decodeFromString(fileText) } companion object { - fun create(context: Context): StorageFileCache { - val storage = StorageFileCache(context) - storage.readFile() - return storage + fun create(context: Context): DiskStorage { + return StorageFileCache(context) } } -} \ No newline at end of file +} + +internal fun toCacheData( + resolvedFlags: List, + resolveToken: String, + evaluationContext: EvaluationContext +) = CacheData( + values = resolvedFlags.associate { + it.flag to ProviderCache.CacheEntry( + it.variant, + Value.Structure(it.value.asMap()), + it.reason + ) + }, + evaluationContextHash = evaluationContext.hashCode(), + resolveToken = resolveToken +) + +@Serializable +data class CacheData( + val resolveToken: String, + val evaluationContextHash: Int, + val values: Map +) \ No newline at end of file diff --git a/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceFeatureProviderTests.kt b/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceFeatureProviderTests.kt index b9639314..50495433 100644 --- a/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceFeatureProviderTests.kt +++ b/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceFeatureProviderTests.kt @@ -15,6 +15,7 @@ import dev.openfeature.contrib.providers.apply.EventStatus import dev.openfeature.contrib.providers.apply.FlagsAppliedMap import dev.openfeature.contrib.providers.apply.json import dev.openfeature.contrib.providers.cache.InMemoryCache +import dev.openfeature.contrib.providers.cache.toCacheData import dev.openfeature.contrib.providers.client.AppliedFlag import dev.openfeature.contrib.providers.client.ConfidenceClient import dev.openfeature.contrib.providers.client.Flags @@ -650,7 +651,8 @@ internal class ConfidenceFeatureProviderTests { ) // Simulate a case where the context in the cache is not synced with the evaluation's context - cache.refresh(resolvedFlags.list, "token2", ImmutableContext("user1")) + val cacheData = toCacheData(resolvedFlags.list, "token2", ImmutableContext("user1")) + cache.refresh(cacheData) val evalString = confidenceFeatureProvider.getStringEvaluation("test-kotlin-flag-1.mystring", "default", ImmutableContext("user2")) val evalBool = confidenceFeatureProvider.getBooleanEvaluation("test-kotlin-flag-1.myboolean", true, ImmutableContext("user2")) val evalInteger = confidenceFeatureProvider.getIntegerEvaluation("test-kotlin-flag-1.myinteger", 1, ImmutableContext("user2")) @@ -736,7 +738,12 @@ internal class ConfidenceFeatureProviderTests { ) ) - cache.refresh(resolvedFlagInvalidKey.list, "token", ImmutableContext("user1")) + val cacheData = toCacheData( + resolvedFlagInvalidKey.list, + "token", + ImmutableContext("user1") + ) + cache.refresh(cacheData) val evalString = confidenceFeatureProvider.getStringEvaluation("test-kotlin-flag-1.mystring", "default", ImmutableContext("user1")) assertEquals("default", evalString.value) assertEquals(Reason.ERROR.toString(), evalString.reason) @@ -810,7 +817,12 @@ internal class ConfidenceFeatureProviderTests { ) // Simulate a case where the context in the cache is not synced with the evaluation's context // This shouldn't have an effect in this test, given that not found values are priority over stale values - cache.refresh(resolvedFlags.list, "token2", ImmutableContext("user1")) + val cacheData = toCacheData( + resolvedFlags.list, + "token2", + ImmutableContext("user1") + ) + cache.refresh(cacheData) val ex = assertThrows(FlagNotFoundError::class.java) { confidenceFeatureProvider.getStringEvaluation("test-kotlin-flag-2.mystring", "default", ImmutableContext("user2")) } @@ -853,7 +865,7 @@ internal class ConfidenceFeatureProviderTests { whenever(mockClient.resolve(eq(listOf()), any())).thenReturn(ResolveResponse.NotModified) confidenceFeatureProvider.initialize(ImmutableContext("user1")) advanceUntilIdle() - verify(cache, never()).refresh(any(), any(), any()) + verify(cache, never()).refresh(any()) } @Test diff --git a/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceIntegrationTests.kt b/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceIntegrationTests.kt index 372124f4..ed7d2994 100644 --- a/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceIntegrationTests.kt +++ b/Provider/src/test/java/dev/openfeature/contrib/providers/ConfidenceIntegrationTests.kt @@ -2,8 +2,11 @@ package dev.openfeature.contrib.providers import android.content.Context import dev.openfeature.contrib.providers.cache.FLAGS_FILE_NAME -import dev.openfeature.contrib.providers.cache.InMemoryCache +import dev.openfeature.contrib.providers.cache.StorageFileCache +import dev.openfeature.contrib.providers.client.ResolveReason +import dev.openfeature.contrib.providers.client.ResolvedFlag import dev.openfeature.sdk.ImmutableContext +import dev.openfeature.sdk.ImmutableStructure import dev.openfeature.sdk.OpenFeatureAPI import dev.openfeature.sdk.Reason import dev.openfeature.sdk.Value @@ -30,13 +33,79 @@ class ConfidenceIntegrationTests { @Before fun setup() { whenever(mockContext.filesDir).thenReturn(Files.createTempDirectory("tmpTests").toFile()) - EventHandler.eventsPublisher().publish(OpenFeatureEvents.ProviderShutDown) + EventHandler.eventsPublisher().publish(OpenFeatureEvents.ProviderStale) + } + + @Test + fun testActivateAndFetchReadsTheLastValue() { + val resolveToken = + "AUi7izywlL0AEMyOVefLST+kYqzuO1PRJzXEEKNHTZCySkmuuEgg5J3rvZhwO+8/f/aOrWjTmVbby3lz2AWEQJjHqbmnvIo7OurF3buyxC7xWp7Ivn7N5+oZC/NoLF7mVEIHGo+dRWN/b0z1rTBXasMwV3HzPc03aRHb47WNG0A2asYsVERWBC9veXi8OSOPnx/aJrbBz7ROwdrr87Lp3C60GgO3P2RxVADZrI5BJzSlLv3jAyWFh563cdaqTCmjUp/iaWilYRqlXSGLkvUqdh40KlUpmIfdLvZ8gxbgq7muzzZuegTq6FMxMhxIvErO6quPN4MSPaoVX2cJ7601s5OZ0idsHvBH4TJPzOWOrn9BYJ9JXrdoblbyUfyXBOS0UsLh6O0ftD02TVd8VgWYNO8RrVDmtfsXkPhcSGIB3SuzgXgLhMZaGfy1Yd7U6EwQMx+Q0AY8fPfM9cGC9bz7N4/JvRJx2mRl+3I8ellH0VFzIhdMkzeRzE1T5Zo0NYvLPuf1n54FES10pEenrcjr2YJwm5uPzxNf+5sb0juD40jzzdVrSu5/CFP3i5orGyLWr0WOuCuQ1IbYl/lwWnjHLOuJfaOJJkcD6On2UpZkDrrt6Lis6I1Lt0QLOtxFugNHOTanRziexdtSqevehXC7JXNeCvdfAxNGbZd2AlH14rU+KMVMIvz77RbTS0t2FyHVufgb/nN6SAHfj7tC9TzRIQnlYLSzM3MMkK2VNtSpL8TW9OM4RG0Xuby0AU6KvBY4Wz++f+iC6pRI/1GKh4XzcUPFXnyh2hYz97A2t3WCnN+tWHdit2ozL+KNm/Ac3dfBkuonZhyTXpSV0Q==" + + val storedValue = 10 + + val context = ImmutableContext( + targetingKey = UUID.randomUUID().toString(), + attributes = mutableMapOf( + "user" to Value.Structure( + mapOf( + "country" to Value.String("SE") + ) + ) + ) + ) + + StorageFileCache.create(mockContext).apply { + val flags = listOf( + ResolvedFlag( + "test-flag-1", + variant = "flags/test-flag-1/off", + reason = ResolveReason.RESOLVE_REASON_MATCH, + value = ImmutableStructure( + mapOf("my-integer" to Value.Integer(storedValue)) + ) + ) + ) + + store( + flags, + resolveToken, + context + ) + } + + OpenFeatureAPI.setProvider( + ConfidenceFeatureProvider.create( + mockContext, + clientSecret, + initialisationStrategy = InitialisationStrategy.ActivateAndFetchAsync + ), + context + ) + runBlocking { + awaitProviderReady() + } + + val intDetails = OpenFeatureAPI.getClient() + .getIntegerDetails( + "test-flag-1.my-integer", + 0 + ) + assertNull(intDetails.errorCode) + assertNull(intDetails.errorMessage) + assertNotNull(intDetails.value) + assertEquals(storedValue, intDetails.value) + assertEquals(Reason.TARGETING_MATCH.name, intDetails.reason) + assertNotNull(intDetails.variant) } @Test fun testSimpleResolveInMemoryCache() { OpenFeatureAPI.setProvider( - ConfidenceFeatureProvider.create(mockContext, clientSecret, cache = InMemoryCache()), + ConfidenceFeatureProvider.create( + mockContext, + clientSecret, + initialisationStrategy = InitialisationStrategy.FetchAndActivate + ), ImmutableContext( targetingKey = UUID.randomUUID().toString(), attributes = mutableMapOf( diff --git a/Provider/src/test/java/dev/openfeature/contrib/providers/StorageFileCacheTests.kt b/Provider/src/test/java/dev/openfeature/contrib/providers/StorageFileCacheTests.kt index 3027876b..d1d56e20 100644 --- a/Provider/src/test/java/dev/openfeature/contrib/providers/StorageFileCacheTests.kt +++ b/Provider/src/test/java/dev/openfeature/contrib/providers/StorageFileCacheTests.kt @@ -1,7 +1,7 @@ package dev.openfeature.contrib.providers import android.content.Context -import dev.openfeature.contrib.providers.cache.StorageFileCache +import dev.openfeature.contrib.providers.cache.InMemoryCache import dev.openfeature.contrib.providers.client.ConfidenceClient import dev.openfeature.contrib.providers.client.Flags import dev.openfeature.contrib.providers.client.ResolveFlags @@ -13,8 +13,11 @@ import dev.openfeature.sdk.ImmutableStructure import dev.openfeature.sdk.Reason import dev.openfeature.sdk.Value import dev.openfeature.sdk.async.awaitProviderReady +import dev.openfeature.sdk.events.EventHandler +import dev.openfeature.sdk.events.OpenFeatureEvents import junit.framework.TestCase import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -61,12 +64,16 @@ class StorageFileCacheTests { @Test fun testOfflineScenarioLoadsStoredCache() = runTest { val mockClient: ConfidenceClient = mock() - val cache1 = StorageFileCache.create(mockContext) + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + val eventPublisher = EventHandler.eventsPublisher(testDispatcher) + eventPublisher.publish(OpenFeatureEvents.ProviderStale) + val cache1 = InMemoryCache() whenever(mockClient.resolve(eq(listOf()), any())).thenReturn(ResolveResponse.Resolved(ResolveFlags(resolvedFlags, "token1"))) val provider1 = ConfidenceFeatureProvider.create( context = mockContext, clientSecret = "", client = mockClient, + eventsPublisher = eventPublisher, cache = cache1 ) runBlocking { @@ -75,14 +82,14 @@ class StorageFileCacheTests { } // Simulate offline scenario whenever(mockClient.resolve(eq(listOf()), any())).thenThrow(Error()) - // Create new cache to force reading cache data from storage - val cache2 = StorageFileCache.create(mockContext) val provider2 = ConfidenceFeatureProvider.create( context = mockContext, clientSecret = "", client = mockClient, - cache = cache2 + cache = InMemoryCache() ) + provider2.initialize(ImmutableContext("user1")) + val evalString = provider2.getStringEvaluation("test-kotlin-flag-1.mystring", "default", ImmutableContext("user1")) val evalBool = provider2.getBooleanEvaluation("test-kotlin-flag-1.myboolean", true, ImmutableContext("user1")) val evalInteger = provider2.getIntegerEvaluation("test-kotlin-flag-1.myinteger", 1, ImmutableContext("user1"))