Refresh Safety
Hot-reloading configuration is safe because Konditional uses atomic snapshot replacement with lock-free reads. Readers never see partial updates, and there's no risk of torn reads or race conditions.
The Guarantee
When you call Namespace.load(newConfiguration), the update is atomic:
// Thread 1: Update configuration
AppFeatures.load(newConfig)
// Thread 2: Concurrent evaluation
val value = AppFeatures.darkMode.evaluate(context) // Sees old OR new, never mixed
What's guaranteed:
- Readers see either the entire old snapshot or the entire new snapshot
- Readers never see a partially-applied configuration
- No locks or synchronization required for reads
How It Works: AtomicReference Swap
Internally, NamespaceRegistry stores the active configuration in an AtomicReference:
// Simplified: the default in-memory NamespaceRegistry implementation.
private val current: AtomicReference<Configuration> = AtomicReference(initialConfiguration)
override fun load(config: Configuration) {
current.set(config) // Single atomic write
}
override val configuration: Configuration
get() = current.get() // Lock-free atomic read
Why this is safe:
- AtomicReference guarantees atomicity —
set(...)is a single write operation - Happens-before relationship — JVM memory model guarantees that writes are visible to subsequent reads
- No torn reads — Reference swap is atomic at the hardware level (no partial writes)
See Theory: Atomicity Guarantees for the formal proof.
Lock-Free Reads
Evaluation reads the current snapshot without blocking writers:
fun <T : Any, C : Context, M : Namespace> Feature<T, C, M>.evaluate(
context: C,
registry: NamespaceRegistry,
): T {
val config = registry.configuration // Lock-free atomic read
// ... evaluate using config ...
}
Benefits:
- No contention — Multiple threads can evaluate flags concurrently
- No blocking — Writers don't block readers, readers don't block writers
- Predictable latency — No lock acquisition overhead
What Can Go Wrong (and What Can't)
✓ Safe: Concurrent Evaluation During Update
// Thread 1
AppFeatures.load(newConfig)
// Thread 2
val v1 = AppFeatures.darkMode.evaluate(ctx1)
val v2 = AppFeatures.apiEndpoint.evaluate(ctx2)
Outcome: Each evaluation sees either the old snapshot or the new snapshot; if a refresh happens between calls, v1
and v2 may observe different
snapshots, but neither call can observe a partially-applied update.
✓ Safe: Multiple Concurrent Updates
// Thread 1
AppFeatures.load(config1)
// Thread 2
AppFeatures.load(config2)
Outcome: Last write wins (atomic reference swap is linearizable). Readers see one of the two configs.
✗ Unsafe: Mutating Configuration After Load
// DON'T DO THIS
val config = AppFeatures.configuration
val mutated = mutateSomehow(config) // Hypothetical mutation
AppFeatures.load(mutated)
Issue: Configuration must be treated as an immutable snapshot. Mutating an existing snapshot breaks the mental
model (evaluations could observe changes that did not come from load(...)).
Precondition: Use load(...) + snapshot decoding
The safety guarantee depends on using the public API:
// ✓ Correct
val _ = AppFeatures // ensure features are registered before parsing
when (val result = ConfigurationSnapshotCodec.decode(json)) {
is ParseResult.Success -> AppFeatures.load(result.value)
is ParseResult.Failure -> logError(result.error.message)
}
// ✓ Correct (side-effecting loader parses + loads on success)
when (val result = NamespaceSnapshotLoader(AppFeatures).load(json)) {
is ParseResult.Success -> logger.info("Config refreshed")
is ParseResult.Failure -> logError(result.error.message)
}
Rollback Is Also Safe
Rollback uses the same atomic swap mechanism:
val success = AppFeatures.rollback(steps = 1)
How it works:
- Registry keeps a bounded history of prior configurations
rollback(...)retrieves the prior snapshot- Atomically swaps it back as the active config
Same safety guarantees apply: readers see old OR new, never mixed.
Integration Patterns
Polling with Refresh
while (running) {
val json = fetchFromServer()
when (val result = NamespaceSnapshotLoader(AppFeatures).load(json)) {
is ParseResult.Success -> {
// Safe: atomic update, concurrent evaluations see consistent snapshot
logger.info("Config refreshed")
}
is ParseResult.Failure -> {
// Safe: last-known-good remains active
logger.error("Refresh failed: ${result.error}")
}
}
delay(pollInterval)
}
Push-Based Refresh
configStream.collect { json ->
when (val result = NamespaceSnapshotLoader(AppFeatures).load(json)) {
is ParseResult.Success -> logger.info("Config updated")
is ParseResult.Failure -> logger.error("Update failed: ${result.error}")
}
}
Why Refresh Can't Break Production
- Atomic updates — Partial config updates are impossible
- Lock-free reads — No deadlocks, no contention
- Fail-safe parsing — Invalid JSON is rejected before affecting evaluation
- Last-known-good fallback — Parse failures don't corrupt active config
- Rollback support — Operational recovery if a bad config sneaks through
Next Steps
- Theory: Atomicity Guarantees — Formal proof of atomic swap safety
- Configuration Lifecycle — JSON → ParseResult → load
- Failure Modes — What can go wrong and how to handle it
- API Reference: Namespace Operations — Full API for load/rollback