feat(alerting): Postgres repository for alert_instances with inbox queries

Implements AlertInstanceRepository: save (upsert), findById, findOpenForRule,
listForInbox (3-way OR: user/group/role via && array-overlap + ANY), countUnreadForUser
(LEFT JOIN alert_reads), ack, resolve, markSilenced, deleteResolvedBefore.
Integration test covers all 9 scenarios including inbox fan-out across all
three target types. Also adds @JsonIgnoreProperties(ignoreUnknown=true) to
SilenceMatcher to suppress Jackson serializing isWildcard() as a round-trip field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:04:51 +02:00
parent 930ac20d11
commit 45028de1db
3 changed files with 446 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.core.alerting.*;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.Array;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.*;
public class PostgresAlertInstanceRepository implements AlertInstanceRepository {
private final JdbcTemplate jdbc;
private final ObjectMapper om;
public PostgresAlertInstanceRepository(JdbcTemplate jdbc, ObjectMapper om) {
this.jdbc = jdbc;
this.om = om;
}
@Override
public AlertInstance save(AlertInstance i) {
String sql = """
INSERT INTO alert_instances (
id, rule_id, rule_snapshot, environment_id, state, severity,
fired_at, acked_at, acked_by, resolved_at, last_notified_at,
silenced, current_value, threshold, context, title, message,
target_user_ids, target_group_ids, target_role_names)
VALUES (?, ?, ?::jsonb, ?, ?::alert_state_enum, ?::severity_enum,
?, ?, ?, ?, ?,
?, ?, ?, ?::jsonb, ?, ?,
?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
state = EXCLUDED.state,
acked_at = EXCLUDED.acked_at,
acked_by = EXCLUDED.acked_by,
resolved_at = EXCLUDED.resolved_at,
last_notified_at = EXCLUDED.last_notified_at,
silenced = EXCLUDED.silenced,
current_value = EXCLUDED.current_value,
threshold = EXCLUDED.threshold,
context = EXCLUDED.context,
title = EXCLUDED.title,
message = EXCLUDED.message,
target_user_ids = EXCLUDED.target_user_ids,
target_group_ids = EXCLUDED.target_group_ids,
target_role_names = EXCLUDED.target_role_names
""";
Array userIds = toTextArray(i.targetUserIds());
Array groupIds = toUuidArray(i.targetGroupIds());
Array roleNames = toTextArray(i.targetRoleNames());
jdbc.update(sql,
i.id(), i.ruleId(), writeJson(i.ruleSnapshot()),
i.environmentId(), i.state().name(), i.severity().name(),
ts(i.firedAt()), ts(i.ackedAt()), i.ackedBy(),
ts(i.resolvedAt()), ts(i.lastNotifiedAt()),
i.silenced(), i.currentValue(), i.threshold(),
writeJson(i.context()), i.title(), i.message(),
userIds, groupIds, roleNames);
return i;
}
@Override
public Optional<AlertInstance> findById(UUID id) {
var list = jdbc.query("SELECT * FROM alert_instances WHERE id = ?", rowMapper(), id);
return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
}
@Override
public Optional<AlertInstance> findOpenForRule(UUID ruleId) {
var list = jdbc.query("""
SELECT * FROM alert_instances
WHERE rule_id = ?
AND state IN ('PENDING','FIRING','ACKNOWLEDGED')
LIMIT 1
""", rowMapper(), ruleId);
return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
}
@Override
public List<AlertInstance> listForInbox(UUID environmentId,
List<String> userGroupIdFilter,
String userId,
List<String> userRoleNames,
int limit) {
// Build arrays for group UUIDs and role names
Array groupArray = toUuidArrayFromStrings(userGroupIdFilter);
Array roleArray = toTextArray(userRoleNames);
String sql = """
SELECT * FROM alert_instances
WHERE environment_id = ?
AND (
? = ANY(target_user_ids)
OR target_group_ids && ?
OR target_role_names && ?
)
ORDER BY fired_at DESC
LIMIT ?
""";
return jdbc.query(sql, rowMapper(), environmentId, userId, groupArray, roleArray, limit);
}
@Override
public long countUnreadForUser(UUID environmentId, String userId) {
String sql = """
SELECT COUNT(*) FROM alert_instances ai
WHERE ai.environment_id = ?
AND ? = ANY(ai.target_user_ids)
AND NOT EXISTS (
SELECT 1 FROM alert_reads ar
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id
)
""";
Long count = jdbc.queryForObject(sql, Long.class, environmentId, userId, userId);
return count == null ? 0L : count;
}
@Override
public void ack(UUID id, String userId, Instant when) {
jdbc.update("""
UPDATE alert_instances
SET state = 'ACKNOWLEDGED'::alert_state_enum,
acked_at = ?, acked_by = ?
WHERE id = ?
""", Timestamp.from(when), userId, id);
}
@Override
public void resolve(UUID id, Instant when) {
jdbc.update("""
UPDATE alert_instances
SET state = 'RESOLVED'::alert_state_enum,
resolved_at = ?
WHERE id = ?
""", Timestamp.from(when), id);
}
@Override
public void markSilenced(UUID id, boolean silenced) {
jdbc.update("UPDATE alert_instances SET silenced = ? WHERE id = ?", silenced, id);
}
@Override
public void deleteResolvedBefore(Instant cutoff) {
jdbc.update("""
DELETE FROM alert_instances
WHERE state = 'RESOLVED'::alert_state_enum
AND resolved_at < ?
""", Timestamp.from(cutoff));
}
// -------------------------------------------------------------------------
private RowMapper<AlertInstance> rowMapper() {
return (rs, i) -> {
try {
Map<String, Object> snapshot = om.readValue(
rs.getString("rule_snapshot"), new TypeReference<>() {});
Map<String, Object> context = om.readValue(
rs.getString("context"), new TypeReference<>() {});
Timestamp ackedAt = rs.getTimestamp("acked_at");
Timestamp resolvedAt = rs.getTimestamp("resolved_at");
Timestamp lastNotifiedAt = rs.getTimestamp("last_notified_at");
Object cvObj = rs.getObject("current_value");
Double currentValue = cvObj == null ? null : ((Number) cvObj).doubleValue();
Object thObj = rs.getObject("threshold");
Double threshold = thObj == null ? null : ((Number) thObj).doubleValue();
UUID ruleId = rs.getObject("rule_id") == null ? null : (UUID) rs.getObject("rule_id");
return new AlertInstance(
(UUID) rs.getObject("id"),
ruleId,
snapshot,
(UUID) rs.getObject("environment_id"),
AlertState.valueOf(rs.getString("state")),
AlertSeverity.valueOf(rs.getString("severity")),
rs.getTimestamp("fired_at").toInstant(),
ackedAt == null ? null : ackedAt.toInstant(),
rs.getString("acked_by"),
resolvedAt == null ? null : resolvedAt.toInstant(),
lastNotifiedAt == null ? null : lastNotifiedAt.toInstant(),
rs.getBoolean("silenced"),
currentValue,
threshold,
context,
rs.getString("title"),
rs.getString("message"),
readTextArray(rs.getArray("target_user_ids")),
readUuidArray(rs.getArray("target_group_ids")),
readTextArray(rs.getArray("target_role_names")));
} catch (Exception e) {
throw new IllegalStateException("Failed to map alert_instances row", e);
}
};
}
private String writeJson(Object o) {
try { return om.writeValueAsString(o); }
catch (Exception e) { throw new IllegalStateException("Failed to serialize JSON", e); }
}
private Timestamp ts(Instant instant) {
return instant == null ? null : Timestamp.from(instant);
}
private Array toTextArray(List<String> items) {
return jdbc.execute((ConnectionCallback<Array>) conn ->
conn.createArrayOf("text", items.toArray()));
}
private Array toUuidArray(List<UUID> ids) {
return jdbc.execute((ConnectionCallback<Array>) conn ->
conn.createArrayOf("uuid", ids.toArray()));
}
private Array toUuidArrayFromStrings(List<String> ids) {
return jdbc.execute((ConnectionCallback<Array>) conn ->
conn.createArrayOf("uuid",
ids.stream().map(UUID::fromString).toArray()));
}
private List<String> readTextArray(Array arr) throws SQLException {
if (arr == null) return List.of();
Object[] raw = (Object[]) arr.getArray();
List<String> out = new ArrayList<>(raw.length);
for (Object o : raw) out.add((String) o);
return out;
}
private List<UUID> readUuidArray(Array arr) throws SQLException {
if (arr == null) return List.of();
Object[] raw = (Object[]) arr.getArray();
List<UUID> out = new ArrayList<>(raw.length);
for (Object o : raw) out.add((UUID) o);
return out;
}
}