fix(alerting): populate AlertInstance.rule_snapshot so history survives rule delete

- Add withRuleSnapshot(Map) wither to AlertInstance (same pattern as other withers)
- Call snapshotRule(rule) + withRuleSnapshot in both applyResult (single-firing) and
  applyBatchFiring paths so every persisted instance carries a non-empty JSONB snapshot
- Strip null values from the Jackson-serialized map before wrapping in the immutable
  snapshot so Map.copyOf in the compact ctor does not throw NPE on nullable rule fields
- Add ruleSnapshotIsPersistedOnInstanceCreation IT: asserts name/severity/conditionKind
  appear in the rule_snapshot column after a tick fires an instance
- Add historySurvivesRuleDelete IT: fires an instance, deletes the rule, asserts
  rule_id IS NULL and rule_snapshot still contains the rule name (spec §5 guarantee)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 20:09:28 +02:00
parent 15c0a8273c
commit bf178ba141
3 changed files with 66 additions and 3 deletions

View File

@@ -161,7 +161,8 @@ public class AlertEvaluatorJob implements SchedulingConfigurer {
&& current.state() == AlertState.PENDING
&& next.state() == AlertState.FIRING;
AlertInstance enriched = enrichTitleMessage(rule, next);
AlertInstance withSnapshot = next.withRuleSnapshot(snapshotRule(rule));
AlertInstance enriched = enrichTitleMessage(rule, withSnapshot);
AlertInstance persisted = instanceRepo.save(enriched);
if (isFirstFire || promotedFromPending) {
@@ -176,7 +177,8 @@ public class AlertEvaluatorJob implements SchedulingConfigurer {
*/
private void applyBatchFiring(AlertRule rule, EvalResult.Firing f) {
Instant now = Instant.now(clock);
AlertInstance instance = AlertStateTransitions.newInstance(rule, f, AlertState.FIRING, now);
AlertInstance instance = AlertStateTransitions.newInstance(rule, f, AlertState.FIRING, now)
.withRuleSnapshot(snapshotRule(rule));
AlertInstance enriched = enrichTitleMessage(rule, instance);
AlertInstance persisted = instanceRepo.save(enriched);
enqueueNotifications(rule, persisted, now);
@@ -236,7 +238,12 @@ public class AlertEvaluatorJob implements SchedulingConfigurer {
@SuppressWarnings("unchecked")
Map<String, Object> snapshotRule(AlertRule rule) {
try {
return objectMapper.convertValue(rule, Map.class);
Map<String, Object> raw = objectMapper.convertValue(rule, Map.class);
// Map.copyOf (used in AlertInstance compact ctor) rejects null values —
// strip them so the snapshot is safe to store.
Map<String, Object> safe = new java.util.LinkedHashMap<>();
raw.forEach((k, v) -> { if (v != null) safe.put(k, v); });
return safe;
} catch (Exception e) {
log.warn("Failed to snapshot rule {}: {}", rule.id(), e.getMessage());
return Map.of("id", rule.id().toString(), "name", rule.name());