Skip to main content
Version: v0.13

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

import com.russhwolf.settings.PreferencesSettings
import java.util.prefs.Preferences

val settings = PreferencesSettings(
Preferences.userRoot().node("com.myapp.config")
)
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 seamlessly 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.host
  • TENANT:my-tenant:feature.enabled
  • PRINCIPAL: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())
)