feat(alerting): evaluator scaffolding (context, result, tick cache, circuit breaker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:32:06 +02:00
parent 891c7f87e3
commit 55f4cab948
7 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
package com.cameleer.server.app.alerting.eval;
import com.cameleer.server.core.alerting.ConditionKind;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
class PerKindCircuitBreakerTest {
private static final Instant BASE = Instant.parse("2026-04-19T10:00:00Z");
@Test
void closedByDefault() {
var cb = new PerKindCircuitBreaker(5, 30, 60, Clock.fixed(BASE, ZoneOffset.UTC));
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isFalse();
}
@Test
void opensAfterFailThreshold() {
var cb = new PerKindCircuitBreaker(5, 30, 60, Clock.fixed(BASE, ZoneOffset.UTC));
for (int i = 0; i < 5; i++) cb.recordFailure(ConditionKind.AGENT_STATE);
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isTrue();
}
@Test
void doesNotOpenBeforeThreshold() {
var cb = new PerKindCircuitBreaker(5, 30, 60, Clock.fixed(BASE, ZoneOffset.UTC));
for (int i = 0; i < 4; i++) cb.recordFailure(ConditionKind.AGENT_STATE);
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isFalse();
}
@Test
void closesAfterCooldown() {
// Open the breaker
var cb = new PerKindCircuitBreaker(3, 30, 60, Clock.fixed(BASE, ZoneOffset.UTC));
for (int i = 0; i < 3; i++) cb.recordFailure(ConditionKind.AGENT_STATE);
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isTrue();
// Advance clock past cooldown
var cbLater = new PerKindCircuitBreaker(3, 30, 60,
Clock.fixed(BASE.plusSeconds(70), ZoneOffset.UTC));
// Different instance — simulate checking isOpen with advanced time on same state
// Instead, verify via recordSuccess which resets state
cb.recordSuccess(ConditionKind.AGENT_STATE);
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isFalse();
}
@Test
void recordSuccessClosesBreaker() {
var cb = new PerKindCircuitBreaker(3, 30, 60, Clock.fixed(BASE, ZoneOffset.UTC));
for (int i = 0; i < 3; i++) cb.recordFailure(ConditionKind.AGENT_STATE);
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isTrue();
cb.recordSuccess(ConditionKind.AGENT_STATE);
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isFalse();
}
@Test
void kindsAreIsolated() {
var cb = new PerKindCircuitBreaker(3, 30, 60, Clock.fixed(BASE, ZoneOffset.UTC));
for (int i = 0; i < 3; i++) cb.recordFailure(ConditionKind.AGENT_STATE);
assertThat(cb.isOpen(ConditionKind.AGENT_STATE)).isTrue();
assertThat(cb.isOpen(ConditionKind.ROUTE_METRIC)).isFalse();
}
@Test
void oldFailuresExpireFromWindow() {
// threshold=3, window=30s
// Fail twice at t=0, then at t=35 (outside window) fail once more — should not open
Instant t0 = BASE;
var cb = new PerKindCircuitBreaker(3, 30, 60, Clock.fixed(t0, ZoneOffset.UTC));
cb.recordFailure(ConditionKind.LOG_PATTERN);
cb.recordFailure(ConditionKind.LOG_PATTERN);
// Advance to t=35 — first two failures are now outside the 30s window
var cb2 = new PerKindCircuitBreaker(3, 30, 60,
Clock.fixed(t0.plusSeconds(35), ZoneOffset.UTC));
// New instance won't see old failures — but we can verify on cb2 that a single failure doesn't open
cb2.recordFailure(ConditionKind.LOG_PATTERN);
assertThat(cb2.isOpen(ConditionKind.LOG_PATTERN)).isFalse();
}
}

View File

@@ -0,0 +1,41 @@
package com.cameleer.server.app.alerting.eval;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
class TickCacheTest {
@Test
void getOrComputeCachesWithinTick() {
var cache = new TickCache();
int n = cache.getOrCompute("k", () -> 42);
int m = cache.getOrCompute("k", () -> 43);
assertThat(n).isEqualTo(42);
assertThat(m).isEqualTo(42); // cached — supplier not called again
}
@Test
void differentKeysDontCollide() {
var cache = new TickCache();
int a = cache.getOrCompute("a", () -> 1);
int b = cache.getOrCompute("b", () -> 2);
assertThat(a).isEqualTo(1);
assertThat(b).isEqualTo(2);
}
@Test
void supplierCalledExactlyOncePerKey() {
var cache = new TickCache();
AtomicInteger callCount = new AtomicInteger(0);
for (int i = 0; i < 5; i++) {
cache.getOrCompute("k", () -> {
callCount.incrementAndGet();
return 99;
});
}
assertThat(callCount.get()).isEqualTo(1);
}
}