feat(alerting): AlertingProperties + AlertStateTransitions state machine
- AlertingProperties @ConfigurationProperties with effective*() accessors and 5000 ms floor clamp on evaluatorTickIntervalMs; warn logged at startup - AlertStateTransitions pure static state machine: Clear/Firing/Batch/Error branches, PENDING→FIRING promotion on forDuration elapsed; Batch delegated to job - AlertInstance wither helpers: withState, withFiredAt, withResolvedAt, withAck, withSilenced, withTitleMessage, withLastNotifiedAt, withContext - AlertingBeanConfig gains @EnableConfigurationProperties(AlertingProperties), alertingInstanceId bean (hostname:pid), alertingClock bean, PerKindCircuitBreaker bean wired from props - 12 unit tests in AlertStateTransitionsTest covering all transitions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package com.cameleer.server.app.alerting.eval;
|
||||
|
||||
import com.cameleer.server.core.alerting.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AlertStateTransitionsTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-04-19T12:00:00Z");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private AlertRule ruleWith(int forDurationSeconds) {
|
||||
return new AlertRule(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "test-rule", null,
|
||||
AlertSeverity.WARNING, true, ConditionKind.AGENT_STATE,
|
||||
new AgentStateCondition(new AlertScope(null, null, null), "DEAD", 60),
|
||||
60, forDurationSeconds, 60,
|
||||
"{{rule.name}} fired", "Alert: {{alert.state}}",
|
||||
List.of(), List.of(),
|
||||
NOW, null, null, Map.of(),
|
||||
NOW, "u1", NOW, "u1");
|
||||
}
|
||||
|
||||
private AlertInstance openInstance(AlertState state, Instant firedAt, String ackedBy) {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), UUID.randomUUID(), Map.of(), UUID.randomUUID(),
|
||||
state, AlertSeverity.WARNING,
|
||||
firedAt, null, ackedBy, null, null, false,
|
||||
1.0, null, Map.of(), "title", "msg",
|
||||
List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
private static final EvalResult.Firing FIRING_RESULT =
|
||||
new EvalResult.Firing(2500.0, 2000.0, Map.of());
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Clear branch
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void clearWithNoOpenInstanceIsNoOp() {
|
||||
var next = AlertStateTransitions.apply(null, EvalResult.Clear.INSTANCE, ruleWith(0), NOW);
|
||||
assertThat(next).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearWithAlreadyResolvedInstanceIsNoOp() {
|
||||
var resolved = openInstance(AlertState.RESOLVED, NOW.minusSeconds(120), null);
|
||||
var next = AlertStateTransitions.apply(resolved, EvalResult.Clear.INSTANCE, ruleWith(0), NOW);
|
||||
assertThat(next).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void firingClearTransitionsToResolved() {
|
||||
var firing = openInstance(AlertState.FIRING, NOW.minusSeconds(90), null);
|
||||
var next = AlertStateTransitions.apply(firing, EvalResult.Clear.INSTANCE, ruleWith(0), NOW);
|
||||
assertThat(next).hasValueSatisfying(i -> {
|
||||
assertThat(i.state()).isEqualTo(AlertState.RESOLVED);
|
||||
assertThat(i.resolvedAt()).isEqualTo(NOW);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void ackedInstanceClearsToResolved() {
|
||||
var acked = openInstance(AlertState.ACKNOWLEDGED, NOW.minusSeconds(30), "alice");
|
||||
var next = AlertStateTransitions.apply(acked, EvalResult.Clear.INSTANCE, ruleWith(0), NOW);
|
||||
assertThat(next).hasValueSatisfying(i -> {
|
||||
assertThat(i.state()).isEqualTo(AlertState.RESOLVED);
|
||||
assertThat(i.resolvedAt()).isEqualTo(NOW);
|
||||
assertThat(i.ackedBy()).isEqualTo("alice"); // preserves acked_by
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Firing branch — no open instance
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void firingWithNoOpenInstanceCreatesPendingIfForDuration() {
|
||||
var rule = ruleWith(60);
|
||||
var next = AlertStateTransitions.apply(null, FIRING_RESULT, rule, NOW);
|
||||
assertThat(next).hasValueSatisfying(i -> {
|
||||
assertThat(i.state()).isEqualTo(AlertState.PENDING);
|
||||
assertThat(i.firedAt()).isEqualTo(NOW);
|
||||
assertThat(i.ruleId()).isEqualTo(rule.id());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void firingWithNoForDurationGoesStraightToFiring() {
|
||||
var rule = ruleWith(0);
|
||||
var next = AlertStateTransitions.apply(null, new EvalResult.Firing(1.0, null, Map.of()), rule, NOW);
|
||||
assertThat(next).hasValueSatisfying(i -> {
|
||||
assertThat(i.state()).isEqualTo(AlertState.FIRING);
|
||||
assertThat(i.firedAt()).isEqualTo(NOW);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Firing branch — PENDING current
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void pendingStaysWhenForDurationNotElapsed() {
|
||||
var rule = ruleWith(60);
|
||||
// firedAt = NOW-10s, forDuration=60s → promoteAt = NOW+50s → still in window
|
||||
var pending = openInstance(AlertState.PENDING, NOW.minusSeconds(10), null);
|
||||
var next = AlertStateTransitions.apply(pending, FIRING_RESULT, rule, NOW);
|
||||
assertThat(next).isEmpty(); // no change
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingPromotesToFiringAfterForDuration() {
|
||||
var rule = ruleWith(60);
|
||||
// firedAt = NOW-120s, forDuration=60s → promoteAt = NOW-60s → elapsed
|
||||
var pending = openInstance(AlertState.PENDING, NOW.minusSeconds(120), null);
|
||||
var next = AlertStateTransitions.apply(pending, FIRING_RESULT, rule, NOW);
|
||||
assertThat(next).hasValueSatisfying(i -> {
|
||||
assertThat(i.state()).isEqualTo(AlertState.FIRING);
|
||||
assertThat(i.firedAt()).isEqualTo(NOW);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Firing branch — already open FIRING / ACKNOWLEDGED
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void firingWhenAlreadyFiringIsNoOp() {
|
||||
var firing = openInstance(AlertState.FIRING, NOW.minusSeconds(120), null);
|
||||
var next = AlertStateTransitions.apply(firing, FIRING_RESULT, ruleWith(0), NOW);
|
||||
assertThat(next).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void firingWhenAcknowledgedIsNoOp() {
|
||||
var acked = openInstance(AlertState.ACKNOWLEDGED, NOW.minusSeconds(30), "alice");
|
||||
var next = AlertStateTransitions.apply(acked, FIRING_RESULT, ruleWith(0), NOW);
|
||||
assertThat(next).isEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Batch + Error → always empty
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void batchResultAlwaysEmpty() {
|
||||
var batch = new EvalResult.Batch(List.of(FIRING_RESULT));
|
||||
var next = AlertStateTransitions.apply(null, batch, ruleWith(0), NOW);
|
||||
assertThat(next).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorResultAlwaysEmpty() {
|
||||
var next = AlertStateTransitions.apply(null,
|
||||
new EvalResult.Error(new RuntimeException("fail")), ruleWith(0), NOW);
|
||||
assertThat(next).isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user