feat(health): EventStore latency + llama-server liveness probes
- EventStoreLatencyProbe: times a side-effect-free lastGlobalSequence read, wired into HealthMonitor; HealthConfig.eventStoreWarnLatencyMs (BACKLOG A §4) - LlamaServerHealthProbe: liveness + tokens/sec degradation trend, injectable client (HttpLlamaLivenessClient over Ktor); deterministic unit tests - llama probe left unregistered (TODO) — HttpClient not in monitor scope yet Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -436,6 +436,13 @@ fun main() {
|
||||
warnBytes = hc.diskWarnBytes,
|
||||
warnGrowthBytesPerMin = hc.diskWarnGrowthBytesPerMin,
|
||||
),
|
||||
com.correx.apps.server.health.EventStoreLatencyProbe.forStore(
|
||||
eventStore,
|
||||
hc.eventStoreWarnLatencyMs,
|
||||
),
|
||||
// TODO(wiring): register LlamaServerHealthProbe once an HttpClient + tokens/sec telemetry are
|
||||
// exposed to the health-monitor scope (currently only built inside
|
||||
// InfrastructureModule.createModelManager).
|
||||
),
|
||||
intervalMs = hc.intervalMs,
|
||||
initialStatuses = seeded,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.correx.apps.server.health
|
||||
|
||||
import com.correx.core.events.events.HealthSubject
|
||||
import com.correx.core.events.stores.EventStore
|
||||
|
||||
private const val NANOS_PER_MILLI = 1_000_000L
|
||||
|
||||
/**
|
||||
* Watches event-log append/fsync latency (observability-spec §4: "the organ that must never lie").
|
||||
* Each tick times one durable, side-effect-free store probe and reports DEGRADED when it exceeds
|
||||
* [warnLatencyMs] — a slow store means the append path (fsync) is contended or the disk is failing.
|
||||
*
|
||||
* The timed operation is injected so the probe never itself appends an event (that would both spam
|
||||
* the health log and break replay determinism). [forStore] wires it in production to
|
||||
* [EventStore.lastGlobalSequence], a durable read that rides the same connection an append's fsync
|
||||
* uses. The latency reading is a live environment observation; it never affects replay (Invariant #8).
|
||||
*/
|
||||
class EventStoreLatencyProbe(
|
||||
private val warnLatencyMs: Long,
|
||||
private val nanoTime: () -> Long = System::nanoTime,
|
||||
private val measure: suspend () -> Unit,
|
||||
) : HealthProbe {
|
||||
|
||||
override val subject: HealthSubject = HealthSubject.EVENT_STORE
|
||||
|
||||
override suspend fun observe(): HealthObservation {
|
||||
val start = nanoTime()
|
||||
val failure = runCatching { measure() }.exceptionOrNull()
|
||||
val elapsedMs = (nanoTime() - start) / NANOS_PER_MILLI
|
||||
|
||||
return when {
|
||||
failure != null -> degraded(
|
||||
value = elapsedMs,
|
||||
detail = "event store probe failed: ${failure.message ?: failure::class.simpleName}",
|
||||
)
|
||||
elapsedMs >= warnLatencyMs -> degraded(
|
||||
value = elapsedMs,
|
||||
detail = "event store probe took ${elapsedMs}ms (warn ≥ ${warnLatencyMs}ms)",
|
||||
)
|
||||
else -> HealthObservation(
|
||||
subject = subject,
|
||||
status = HealthStatus.HEALTHY,
|
||||
metric = "latency_ms",
|
||||
observedValue = elapsedMs,
|
||||
threshold = warnLatencyMs,
|
||||
detail = "event store probe took ${elapsedMs}ms",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun degraded(value: Long, detail: String) = HealthObservation(
|
||||
subject = subject,
|
||||
status = HealthStatus.DEGRADED,
|
||||
metric = "latency_ms",
|
||||
observedValue = value,
|
||||
threshold = warnLatencyMs,
|
||||
detail = detail,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Production wiring: time a durable, side-effect-free read on [store]. */
|
||||
fun forStore(store: EventStore, warnLatencyMs: Long): EventStoreLatencyProbe =
|
||||
EventStoreLatencyProbe(warnLatencyMs = warnLatencyMs) { store.lastGlobalSequence() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.correx.apps.server.health
|
||||
|
||||
import com.correx.core.events.events.HealthSubject
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.http.isSuccess
|
||||
|
||||
private const val DEFAULT_DEGRADE_FRACTION = 0.5
|
||||
private const val DEFAULT_BASELINE_SAMPLES = 5
|
||||
private const val LIVE = 1L
|
||||
private const val DEAD = 0L
|
||||
|
||||
/** One liveness reading of the llama server: reachable, plus optional throughput telemetry. */
|
||||
data class LivenessResult(val reachable: Boolean, val tokensPerSecond: Double? = null)
|
||||
|
||||
/** Pings the local inference process; implementations never throw (failures map to unreachable). */
|
||||
interface LlamaLivenessClient {
|
||||
suspend fun ping(): LivenessResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Ktor-backed liveness client: a `GET $baseUrl/health` whose 2xx status is the only signal.
|
||||
* Tokens/sec telemetry is fed separately (the health endpoint reports reachability only), so this
|
||||
* client never populates [LivenessResult.tokensPerSecond]. Any transport exception is swallowed
|
||||
* into an unreachable result so the probe sees a clean signal.
|
||||
*/
|
||||
class HttpLlamaLivenessClient(
|
||||
private val httpClient: HttpClient,
|
||||
private val baseUrl: String,
|
||||
) : LlamaLivenessClient {
|
||||
override suspend fun ping(): LivenessResult = runCatching {
|
||||
val response = httpClient.get("$baseUrl/health")
|
||||
LivenessResult(reachable = response.status.isSuccess())
|
||||
}.getOrElse { LivenessResult(reachable = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the local inference process (observability-spec §4: "liveness + tokens/sec degradation").
|
||||
* Two failure modes: the server stops answering (DEGRADED on the `liveness` metric), or it stays
|
||||
* up but throughput collapses — KV-cache pressure or thermal throttle. The latter is judged against
|
||||
* a rolling PEAK of the last [baselineSamples] readings: when current tps falls below
|
||||
* [degradeFraction] of that peak, the subject degrades on the `tokens_per_sec` metric.
|
||||
*
|
||||
* The rolling window is per-run live state and never affects replay (Invariant #8): the monitor's
|
||||
* recorded edge events are the sole replay input, so a fresh process starting with an empty window
|
||||
* reconstructs identical history from the log.
|
||||
*/
|
||||
class LlamaServerHealthProbe(
|
||||
private val client: LlamaLivenessClient,
|
||||
private val degradeFraction: Double = DEFAULT_DEGRADE_FRACTION,
|
||||
private val baselineSamples: Int = DEFAULT_BASELINE_SAMPLES,
|
||||
) : HealthProbe {
|
||||
|
||||
override val subject: HealthSubject = HealthSubject.LLAMA_SERVER
|
||||
|
||||
private val recent = ArrayDeque<Double>()
|
||||
|
||||
override suspend fun observe(): HealthObservation {
|
||||
val result = runCatching { client.ping() }.getOrElse { LivenessResult(reachable = false) }
|
||||
|
||||
if (!result.reachable) {
|
||||
return HealthObservation(
|
||||
subject = subject,
|
||||
status = HealthStatus.DEGRADED,
|
||||
metric = "liveness",
|
||||
observedValue = DEAD,
|
||||
threshold = LIVE,
|
||||
detail = "llama server unreachable",
|
||||
)
|
||||
}
|
||||
|
||||
val tps = result.tokensPerSecond
|
||||
?: return HealthObservation(
|
||||
subject = subject,
|
||||
status = HealthStatus.HEALTHY,
|
||||
metric = "liveness",
|
||||
observedValue = LIVE,
|
||||
threshold = LIVE,
|
||||
detail = "llama server reachable",
|
||||
)
|
||||
|
||||
return throughputObservation(tps)
|
||||
}
|
||||
|
||||
private fun throughputObservation(tps: Double): HealthObservation {
|
||||
recent.addLast(tps)
|
||||
while (recent.size > baselineSamples) recent.removeFirst()
|
||||
|
||||
val baseline = recent.maxOrNull() ?: tps
|
||||
val floor = baseline * degradeFraction
|
||||
|
||||
return if (baseline > 0.0 && tps < floor) {
|
||||
HealthObservation(
|
||||
subject = subject,
|
||||
status = HealthStatus.DEGRADED,
|
||||
metric = "tokens_per_sec",
|
||||
observedValue = tps.toLong(),
|
||||
threshold = floor.toLong(),
|
||||
detail = "throughput ${"%.1f".format(tps)} tok/s below ${"%.0f".format(degradeFraction * 100)}% " +
|
||||
"of peak ${"%.1f".format(baseline)} tok/s",
|
||||
)
|
||||
} else {
|
||||
HealthObservation(
|
||||
subject = subject,
|
||||
status = HealthStatus.HEALTHY,
|
||||
metric = "tokens_per_sec",
|
||||
observedValue = tps.toLong(),
|
||||
threshold = floor.toLong(),
|
||||
detail = "throughput ${"%.1f".format(tps)} tok/s (peak ${"%.1f".format(baseline)} tok/s)",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.correx.apps.server.health
|
||||
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventStoreLatencyProbeTest {
|
||||
|
||||
/** Hand-advanced nano source: the [measure] lambda decides how much wall-clock the probe "saw". */
|
||||
private class FakeNanos {
|
||||
var now: Long = 0L
|
||||
fun read(): Long = now
|
||||
fun advanceMs(ms: Long) {
|
||||
now += ms * 1_000_000L
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `healthy when latency under threshold`(): Unit = runBlocking {
|
||||
val nanos = FakeNanos()
|
||||
val probe = EventStoreLatencyProbe(
|
||||
warnLatencyMs = 50,
|
||||
nanoTime = nanos::read,
|
||||
) { nanos.advanceMs(10) }
|
||||
val obs = probe.observe()
|
||||
assertEquals(HealthStatus.HEALTHY, obs.status)
|
||||
assertEquals("latency_ms", obs.metric)
|
||||
assertEquals(10, obs.observedValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `degraded when latency crosses threshold`(): Unit = runBlocking {
|
||||
val nanos = FakeNanos()
|
||||
val probe = EventStoreLatencyProbe(
|
||||
warnLatencyMs = 50,
|
||||
nanoTime = nanos::read,
|
||||
) { nanos.advanceMs(120) }
|
||||
val obs = probe.observe()
|
||||
assertEquals(HealthStatus.DEGRADED, obs.status)
|
||||
assertEquals("latency_ms", obs.metric)
|
||||
assertEquals(120, obs.observedValue)
|
||||
assertEquals(50, obs.threshold)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `degraded when the timed read throws`(): Unit = runBlocking {
|
||||
val probe = EventStoreLatencyProbe(warnLatencyMs = 50) { error("store down") }
|
||||
val obs = probe.observe()
|
||||
assertEquals(HealthStatus.DEGRADED, obs.status)
|
||||
assertEquals("latency_ms", obs.metric)
|
||||
assertTrue(obs.detail.contains("store down"), "detail should name the failure: ${obs.detail}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forStore reads a real store and stays healthy under a generous watermark`(): Unit = runBlocking {
|
||||
val store = InMemoryEventStore()
|
||||
val probe = EventStoreLatencyProbe.forStore(store, warnLatencyMs = Long.MAX_VALUE)
|
||||
assertEquals(HealthStatus.HEALTHY, probe.observe().status)
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.correx.apps.server.health
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class LlamaServerHealthProbeTest {
|
||||
|
||||
/** Replays a queued script of liveness readings; [error] is thrown when the queue runs dry on demand. */
|
||||
private class ScriptedClient(results: List<LivenessResult>, private val throwOnPing: Boolean = false) :
|
||||
LlamaLivenessClient {
|
||||
private val queue = ArrayDeque(results)
|
||||
override suspend fun ping(): LivenessResult {
|
||||
if (throwOnPing) error("ping boom")
|
||||
return queue.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reachable(tps: Double? = null) = LivenessResult(reachable = true, tokensPerSecond = tps)
|
||||
|
||||
@Test
|
||||
fun `reachable with stable throughput stays healthy`(): Unit = runBlocking {
|
||||
val probe = LlamaServerHealthProbe(ScriptedClient(listOf(reachable(100.0), reachable(100.0), reachable(100.0))))
|
||||
assertEquals(HealthStatus.HEALTHY, probe.observe().status)
|
||||
assertEquals(HealthStatus.HEALTHY, probe.observe().status)
|
||||
val obs = probe.observe()
|
||||
assertEquals(HealthStatus.HEALTHY, obs.status)
|
||||
assertEquals("tokens_per_sec", obs.metric)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unreachable degrades on the liveness metric`(): Unit = runBlocking {
|
||||
val probe = LlamaServerHealthProbe(ScriptedClient(listOf(LivenessResult(reachable = false))))
|
||||
val obs = probe.observe()
|
||||
assertEquals(HealthStatus.DEGRADED, obs.status)
|
||||
assertEquals("liveness", obs.metric)
|
||||
assertEquals(0, obs.observedValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reachable without telemetry stays healthy on liveness`(): Unit = runBlocking {
|
||||
val probe = LlamaServerHealthProbe(ScriptedClient(listOf(reachable())))
|
||||
val obs = probe.observe()
|
||||
assertEquals(HealthStatus.HEALTHY, obs.status)
|
||||
assertEquals("liveness", obs.metric)
|
||||
assertEquals(1, obs.observedValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throughput collapse below half of peak degrades, then recovers`(): Unit = runBlocking {
|
||||
val probe = LlamaServerHealthProbe(
|
||||
ScriptedClient(listOf(reachable(100.0), reachable(100.0), reachable(30.0), reachable(100.0))),
|
||||
)
|
||||
assertEquals(HealthStatus.HEALTHY, probe.observe().status)
|
||||
assertEquals(HealthStatus.HEALTHY, probe.observe().status)
|
||||
|
||||
val dropped = probe.observe()
|
||||
assertEquals(HealthStatus.DEGRADED, dropped.status)
|
||||
assertEquals("tokens_per_sec", dropped.metric)
|
||||
assertEquals(30, dropped.observedValue)
|
||||
|
||||
val recovered = probe.observe()
|
||||
assertEquals(HealthStatus.HEALTHY, recovered.status)
|
||||
assertEquals("tokens_per_sec", recovered.metric)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ping that throws is treated as unreachable`(): Unit = runBlocking {
|
||||
val probe = LlamaServerHealthProbe(ScriptedClient(emptyList(), throwOnPing = true))
|
||||
val obs = probe.observe()
|
||||
assertEquals(HealthStatus.DEGRADED, obs.status)
|
||||
assertEquals("liveness", obs.metric)
|
||||
}
|
||||
|
||||
private fun clientFor(status: HttpStatusCode) =
|
||||
HttpLlamaLivenessClient(HttpClient(MockEngine { respond(content = "", status = status) }), "http://llama")
|
||||
|
||||
@Test
|
||||
fun `http client reports reachable on 200`(): Unit = runBlocking {
|
||||
assertEquals(true, clientFor(HttpStatusCode.OK).ping().reachable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `http client reports unreachable on 503`(): Unit = runBlocking {
|
||||
assertEquals(false, clientFor(HttpStatusCode.ServiceUnavailable).ping().reachable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `http client reports unreachable when transport throws`(): Unit = runBlocking {
|
||||
val client = HttpLlamaLivenessClient(
|
||||
HttpClient(MockEngine { error("connection refused") }),
|
||||
"http://llama",
|
||||
)
|
||||
assertEquals(false, client.ping().reachable)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user