Version: v0.25.0 (Latest)
Offline Configuration Cache
The config-offline-cache module provides persistent local caching for configuration, ensuring applications remain functional during network outages.
Overview
The offline cache stores configuration data locally using kmp-settings, enabling:
- Network Resilience - Applications continue working when cloud config is unavailable
- Faster Startup - Cached config loads instantly without network requests
- Multiplatform Support - Works on JVM, Android, iOS, and other Kotlin targets
- Scope Isolation - Separate caches per tenant and config level
Installation
// build.gradle.kts
dependencies {
implementation(project(":lib-conf-config-offline-cache"))
// kmp-settings for persistence
implementation("com.russhwolf:multiplatform-settings:1.1.1")
implementation("com.russhwolf:multiplatform-settings-coroutines:1.1.1")
}
OfflineConfigCache Interface
interface OfflineConfigCache {
/**
* Persist configuration to local storage.
*/
suspend fun persist(
scope: ConfigLevel,
tenantId: String?,
config: Map<String, Any>
)
/**
* Load configuration from local storage.
*/
suspend fun load(
scope: ConfigLevel,
tenantId: String?
): Map<String, Any>?
/**
* Check if cached data is stale.
*/
suspend fun isStale(
scope: ConfigLevel,
tenantId: String?,
maxStaleness: Duration
): Boolean
/**
* Clear cached configuration.
*/
suspend fun clear(
scope: ConfigLevel,
tenantId: String?
)
/**
* Get the timestamp of the last cache update.
*/
suspend fun getLastUpdated(
scope: ConfigLevel,
tenantId: String?
): Instant?
}
Basic Usage
Creating the Cache
- JVM
- Android
- iOS
import com.russhwolf.settings.PreferencesSettings
import java.util.prefs.Preferences
val settings = PreferencesSettings(
Preferences.userRoot().node("com.myapp.config")
)
val offlineCache = KmpSettingsStorage(settings)
import com.russhwolf.settings.SharedPreferencesSettings
import android.content.Context
val settings = SharedPreferencesSettings(
context.getSharedPreferences("config_cache", Context.MODE_PRIVATE)
)
val offlineCache = KmpSettingsStorage(settings)
import com.russhwolf.settings.NSUserDefaultsSettings
import platform.Foundation.NSUserDefaults
val settings = NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
val offlineCache = KmpSettingsStorage(settings)
Persisting Configuration
// After successful cloud refresh, persist to cache
val cloudConfig = mapOf(
"database.host" to "prod-db.example.com",
"database.port" to 5432,
"feature.newUi" to true,
"api.timeout" to 30000
)
offlineCache.persist(
scope = ConfigLevel.APP,
tenantId = "my-tenant",
config = cloudConfig
)
Loading Cached Configuration
// Load from cache when cloud is unavailable
val cachedConfig = offlineCache.load(
scope = ConfigLevel.APP,
tenantId = "my-tenant"
)
if (cachedConfig != null) {
val dbHost = cachedConfig["database.host"] as? String
val dbPort = cachedConfig["database.port"] as? Int
}
Checking Staleness
import kotlin.time.Duration.Companion.hours
val isStale = offlineCache.isStale(
scope = ConfigLevel.APP,
tenantId = "my-tenant",
maxStaleness = 24.hours
)
if (isStale) {
log.warn("Configuration cache is stale, attempting refresh")
try {
cloudProvider.refresh()
} catch (e: Exception) {
log.warn("Refresh failed, continuing with stale cache")
}
}
Integration with Cloud Providers
The offline cache integrates with cloud configuration providers.
REST Config Provider
val restProvider = RestConfigProviderImpl(
config = RestConfigProviderConfig(
baseUrl = "https://config.mycompany.com",
tenantId = "my-tenant",
offlineCacheEnabled = true
),
httpClient = HttpClient(CIO),
offlineCache = KmpSettingsStorage(settings)
)
// On refresh:
// 1. Fetches from REST API
// 2. If successful, updates cache
// 3. If failed, loads from cache
val result = restProvider.refresh()
Azure App Configuration
val azureProvider = AzureAppConfigProvider(
azureConfig = AzureAppConfigProviderConfig(
endpoint = "https://myapp.azconfig.io",
offlineCacheEnabled = true
),
offlineCache = KmpSettingsStorage(settings)
)
Fallback Strategy
The recommended fallback strategy for cloud providers:
class ResilientConfigProvider(
private val cloudProvider: CloudConfigProvider,
private val offlineCache: OfflineConfigCache,
private val maxStaleness: Duration = 24.hours
) {
suspend fun refresh(): IdkResult<RefreshResult, IdkError> {
// Try cloud first
val cloudResult = cloudProvider.refresh()
if (cloudResult.isOk) {
// Cloud succeeded - update cache
offlineCache.persist(
scope = cloudProvider.configLevel,
tenantId = currentTenantId,
config = cloudProvider.source
)
return cloudResult
}
// Cloud failed - try cache
val cached = offlineCache.load(
scope = cloudProvider.configLevel,
tenantId = currentTenantId
)
if (cached != null) {
val isStale = offlineCache.isStale(
scope = cloudProvider.configLevel,
tenantId = currentTenantId,
maxStaleness = maxStaleness
)
if (isStale) {
log.warn("Using stale cached configuration (last updated: ${
offlineCache.getLastUpdated(cloudProvider.configLevel, currentTenantId)
})")
}
return Ok(RefreshResult(
configCount = cached.size,
changedKeys = emptySet(),
refreshedAt = offlineCache.getLastUpdated(
cloudProvider.configLevel,
currentTenantId
) ?: Clock.System.now()
))
}
// Both cloud and cache failed
return cloudResult
}
}
Cache Key Structure
The cache uses a structured key format for isolation:
{scope}:{tenantId}:{key}
Examples:
APP:my-tenant:database.hostTENANT:my-tenant:feature.enabledPRINCIPAL:my-tenant:user.theme
Metadata Keys
Special keys store cache metadata:
_meta:{scope}:{tenantId}:lastUpdated
_meta:{scope}:{tenantId}:configKeys
Scope-Based Storage
Configuration is stored separately per scope and tenant:
// App-level config (shared across all users)
offlineCache.persist(
scope = ConfigLevel.APP,
tenantId = "tenant1",
config = appConfig
)
// Tenant-level config
offlineCache.persist(
scope = ConfigLevel.TENANT,
tenantId = "tenant1",
config = tenantConfig
)
// Principal-level config
offlineCache.persist(
scope = ConfigLevel.PRINCIPAL,
tenantId = "tenant1", // Still needs tenantId for isolation
config = principalConfig
)
Cache Management
Clearing Cache
// Clear specific scope
offlineCache.clear(
scope = ConfigLevel.APP,
tenantId = "tenant1"
)
// Clear all for tenant
offlineCache.clear(
scope = ConfigLevel.APP,
tenantId = "tenant1"
)
offlineCache.clear(
scope = ConfigLevel.TENANT,
tenantId = "tenant1"
)
offlineCache.clear(
scope = ConfigLevel.PRINCIPAL,
tenantId = "tenant1"
)
Cache Statistics
// Get last update time
val lastUpdated = offlineCache.getLastUpdated(
scope = ConfigLevel.APP,
tenantId = "tenant1"
)
// Check staleness
val staleness = if (lastUpdated != null) {
Clock.System.now() - lastUpdated
} else {
Duration.INFINITE
}
log.info("Cache age: ${staleness.inWholeMinutes} minutes")
Serialization
The cache serializes values to JSON for storage:
// Supported types
val config = mapOf(
"stringValue" to "hello",
"intValue" to 42,
"doubleValue" to 3.14,
"boolValue" to true,
"listValue" to listOf("a", "b", "c"),
"mapValue" to mapOf("nested" to "value")
)
offlineCache.persist(scope, tenantId, config)
// Deserialization preserves types
val loaded = offlineCache.load(scope, tenantId)
val str = loaded["stringValue"] as String
val num = loaded["intValue"] as Int
val flag = loaded["boolValue"] as Boolean
Error Handling
try {
offlineCache.persist(scope, tenantId, config)
} catch (e: SerializationException) {
log.error("Failed to serialize config for caching", e)
// Continue without caching
} catch (e: IOException) {
log.error("Failed to write to cache storage", e)
// Continue without caching
}
// Load with fallback
val config = try {
offlineCache.load(scope, tenantId)
} catch (e: Exception) {
log.warn("Failed to load from cache", e)
null
}
Best Practices
1. Always Enable Offline Cache in Production
val provider = RestConfigProviderImpl(
config = RestConfigProviderConfig(
// ... other config
offlineCacheEnabled = true // Essential for resilience
),
offlineCache = offlineCacheImpl
)
2. Set Appropriate Staleness Thresholds
// Short staleness for frequently changing config
val featureFlagsMaxStaleness = 1.hours
// Longer staleness for stable config
val connectionConfigMaxStaleness = 24.hours
if (offlineCache.isStale(scope, tenantId, featureFlagsMaxStaleness)) {
prioritizeRefresh()
}
3. Handle Missing Cache Gracefully
val config = offlineCache.load(scope, tenantId)
?: run {
log.info("No cached config available, using defaults")
defaultConfiguration
}
4. Clear Cache on Logout
fun logout(tenantId: String) {
// Clear user-specific cached config
offlineCache.clear(ConfigLevel.PRINCIPAL, tenantId)
// Optionally clear tenant config too
if (clearTenantDataOnLogout) {
offlineCache.clear(ConfigLevel.TENANT, tenantId)
}
}
5. Log Cache Status at Startup
fun logCacheStatus(tenantId: String) {
val lastUpdated = offlineCache.getLastUpdated(ConfigLevel.APP, tenantId)
if (lastUpdated != null) {
val age = Clock.System.now() - lastUpdated
log.info("Configuration cache available (age: ${age.inWholeMinutes} minutes)")
} else {
log.info("No configuration cache available")
}
}
Platform-Specific Notes
Android
Use SharedPreferences with app-private mode:
val settings = SharedPreferencesSettings(
context.getSharedPreferences(
"config_cache",
Context.MODE_PRIVATE // Not accessible to other apps
)
)
iOS
NSUserDefaults is suitable for small config. For large configs, consider file-based storage:
val settings = NSUserDefaultsSettings(
NSUserDefaults(suiteName = "com.myapp.config")
)
JVM Server
Use file-based preferences for server deployments:
val prefsPath = Paths.get(System.getProperty("user.home"), ".myapp", "config")
val settings = PreferencesSettings(
Preferences.userRoot().node(prefsPath.toString())
)