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:
hsiegeln
2026-04-19 19:58:12 +02:00
parent f8cd3f3ee4
commit 657dc2d407
5 changed files with 461 additions and 0 deletions

View File

@@ -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();
}
}