commonMain.com.configcat.ConfigService.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of configcat-kotlin-client Show documentation
Show all versions of configcat-kotlin-client Show documentation
Kotlin Multiplatform SDK for ConfigCat, a feature flag, feature toggle, and configuration management service. That lets you launch new features and change your software configuration remotely without actually (re)deploying code. ConfigCat even helps you do controlled roll-outs like canary releases and blue-green deployments.
package com.configcat
import com.configcat.fetch.ConfigFetcher
import com.configcat.fetch.RefreshResult
import com.configcat.log.ConfigCatLogMessages
import com.configcat.log.InternalLogger
import com.configcat.model.Entry
import com.configcat.model.Setting
import korlibs.crypto.sha1
import korlibs.time.DateTime
import kotlinx.atomicfu.AtomicRef
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.locks.reentrantLock
import kotlinx.atomicfu.locks.withLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
internal data class SettingResult(val settings: Map, val fetchTime: DateTime) {
fun isEmpty(): Boolean = this === empty
companion object {
val empty: SettingResult = SettingResult(emptyMap(), Constants.distantPast)
}
}
internal class ConfigService(
private val options: ConfigCatOptions,
private val configFetcher: ConfigFetcher,
private val logger: InternalLogger,
private val hooks: Hooks,
) : Closeable {
internal val cacheKey: String = getCacheKey()
private val mutex = Mutex()
private val syncLock = reentrantLock()
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val closed = atomic(false)
private val initialized = atomic(false)
private val cacheStateReported = atomic(false)
private val offline = atomic(options.offline)
private val mode = options.pollingMode
private val fetchJob: AtomicRef>?> = atomic(null)
private var pollingJob: Job? = null
private var cachedEntry = Entry.empty
private var fetching = false
val isOffline: Boolean get() = offline.value
init {
if (mode is AutoPollMode && !options.offline) {
startPoll(mode)
} else {
setInitializedState()
}
}
private fun setInitializedState() {
initialized.value = true
coroutineScope.launch {
mutex.withLock {
if (cachedEntry.isEmpty()) {
cachedEntry = readCache()
}
initializeAndReportCacheState(cachedEntry)
}
}
}
suspend fun getSettings(): SettingResult {
val result =
when (mode) {
is LazyLoadMode -> {
fetchIfOlder(
DateTime.now()
.add(0, -mode.configuration.cacheRefreshInterval.inWholeMilliseconds.toDouble()),
)
}
else -> {
// If we are initialized, we prefer the cached results
val threshold =
if (!initialized.value && mode is AutoPollMode) {
DateTime.now()
.add(0, -mode.configuration.pollingInterval.inWholeMilliseconds.toDouble())
} else {
Constants.distantPast
}
fetchIfOlder(threshold, preferCached = initialized.value)
}
}
return if (result.first.isEmpty()) {
SettingResult.empty
} else {
SettingResult(result.first.config.settings ?: emptyMap(), result.first.fetchTime)
}
}
fun offline() {
syncLock.withLock {
if (!offline.compareAndSet(expect = false, update = true)) return
pollingJob?.cancel()
logger.info(5200, ConfigCatLogMessages.getConfigServiceStatusChanged("OFFLINE"))
}
}
fun online() {
syncLock.withLock {
if (!offline.compareAndSet(expect = true, update = false)) return
if (mode is AutoPollMode) {
startPoll(mode)
}
logger.info(5200, ConfigCatLogMessages.getConfigServiceStatusChanged("ONLINE"))
}
}
suspend fun refresh(): RefreshResult {
if (offline.value) {
val offlineMessage = ConfigCatLogMessages.CONFIG_SERVICE_CANNOT_INITIATE_HTTP_CALLS_WARN
logger.warning(3200, offlineMessage)
return RefreshResult(false, offlineMessage)
}
val result = fetchIfOlder(Constants.distantFuture)
return RefreshResult(result.second == null, result.second)
}
@Suppress("ComplexMethod")
private suspend fun fetchIfOlder(
threshold: DateTime,
preferCached: Boolean = false,
): Pair {
mutex.withLock {
// Sync up with the cache and use it when it's not expired.
val fromCache = readCache()
if (!fromCache.isEmpty() && fromCache.eTag != cachedEntry.eTag) {
hooks.invokeOnConfigChanged(fromCache.config.settings)
cachedEntry = fromCache
}
// Cache isn't expired
if (!cachedEntry.isExpired(threshold)) {
initializeAndReportCacheState(cachedEntry)
return Pair(cachedEntry, null)
}
// If we are in offline mode or the caller prefers cached values, do not initiate fetch.
if (offline.value || preferCached) {
cachedEntry = readCache()
return Pair(cachedEntry, null)
}
if (fetchJob.value == null || !fetching) {
// No fetch is running, initiate a new one.
fetching = true
val eTag = cachedEntry.eTag
fetchJob.value =
coroutineScope.async {
if (mode is AutoPollMode && !initialized.value) {
// Waiting for the client initialization.
// After the maxInitWaitTimeInSeconds timeout the client will be initialized and while
// the config is not ready the default value will be returned.
val result =
withTimeoutOrNull(mode.configuration.maxInitWaitTime) {
fetchConfig(eTag)
}
if (result != null) {
return@async result
}
// We got a timeout
val message =
ConfigCatLogMessages.getAutoPollMaxInitWaitTimeReached(
mode.configuration.maxInitWaitTime.inWholeMilliseconds,
)
logger.warning(4200, message)
initializeAndReportCacheState(cachedEntry)
return@async Pair(Entry.empty, message)
} else {
// The service is initialized, start fetch without timeout.
return@async fetchConfig(eTag)
}
}
}
}
// Await the fetch routine.
val result = fetchJob.value?.await()
mutex.withLock {
return result?.let { value ->
when {
value.first.isEmpty() -> Pair(cachedEntry, value.second)
else -> value
}
} ?: Pair(cachedEntry, null)
}
}
private suspend fun fetchConfig(eTag: String): Pair {
val response = configFetcher.fetch(eTag)
mutex.withLock {
fetching = false
if (response.isFetched) {
cachedEntry = response.entry
writeCache(response.entry)
hooks.invokeOnConfigChanged(response.entry.config.settings)
initializeAndReportCacheState(response.entry)
return Pair(response.entry, null)
} else if ((response.isNotModified || !response.isTransientError) && !cachedEntry.isEmpty()) {
cachedEntry = cachedEntry.copy(fetchTime = DateTime.now())
writeCache(cachedEntry)
}
initializeAndReportCacheState(cachedEntry)
return Pair(cachedEntry, response.error)
}
}
private fun startPoll(mode: AutoPollMode) {
logger.debug("Start polling with ${mode.configuration.pollingInterval.inWholeMilliseconds}ms interval.")
pollingJob =
coroutineScope.launch {
while (isActive) {
fetchIfOlder(
DateTime.now()
.add(0, -mode.configuration.pollingInterval.inWholeMilliseconds.toDouble()),
)
delay(mode.configuration.pollingInterval)
}
}
}
private fun initializeAndReportCacheState(entry: Entry) {
initialized.value = true
if (cacheStateReported.compareAndSet(expect = false, update = true)) {
hooks.invokeOnClientReady(determineCacheState(entry))
}
}
private suspend fun readCache(): Entry {
return try {
val cached = options.configCache?.read(cacheKey) ?: ""
if (cached.isEmpty() || cached == cachedEntry.cacheString) return Entry.empty
Entry.fromString(cached)
} catch (e: Exception) {
logger.error(2200, ConfigCatLogMessages.CONFIG_SERVICE_CACHE_READ_ERROR, e)
Entry.empty
}
}
private suspend fun writeCache(entry: Entry) {
options.configCache?.let { cache ->
try {
cache.write(cacheKey, entry.cacheString)
} catch (e: Exception) {
logger.error(2201, ConfigCatLogMessages.CONFIG_SERVICE_CACHE_WRITE_ERROR, e)
}
}
}
private fun getCacheKey() =
"${options.sdkKey}_${Constants.CONFIG_FILE_NAME}_${Constants.SERIALIZATION_FORMAT_VERSION}".encodeToByteArray()
.sha1().hex
override fun close() {
if (!closed.compareAndSet(expect = false, update = true)) return
configFetcher.close()
coroutineScope.cancel()
}
private fun determineCacheState(cachedEntry: Entry): ClientCacheState {
if (cachedEntry.isEmpty()) {
return ClientCacheState.NO_FLAG_DATA
}
when (mode) {
is ManualPollMode -> {
return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY
}
is LazyLoadMode -> {
if (cachedEntry.isExpired(
DateTime.now()
.add(0, -mode.configuration.cacheRefreshInterval.inWholeMilliseconds.toDouble()),
)
) {
return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY
}
}
is AutoPollMode -> {
if (cachedEntry.isExpired(
DateTime.now()
.add(0, -mode.configuration.pollingInterval.inWholeMilliseconds.toDouble()),
)
) {
return ClientCacheState.HAS_CACHED_FLAG_DATA_ONLY
}
}
}
return ClientCacheState.HAS_UP_TO_DATE_FLAG_DATA
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy