feat(alerting): Postgres repository for alert_rules

Implements AlertRuleRepository with JSONB condition/webhooks/eval_state
serialization via ObjectMapper, UPSERT on conflict, JSONB containment
query for findRuleIdsByOutboundConnectionId, and FOR UPDATE SKIP LOCKED
claim-polling for horizontal scale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 18:48:15 +02:00
parent 1ff256dce0
commit f80bc006c1
2 changed files with 263 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
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.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.*;
public class PostgresAlertRuleRepository implements AlertRuleRepository {
private final JdbcTemplate jdbc;
private final ObjectMapper om;
public PostgresAlertRuleRepository(JdbcTemplate jdbc, ObjectMapper om) {
this.jdbc = jdbc;
this.om = om;
}
@Override
public AlertRule save(AlertRule r) {
String sql = """
INSERT INTO alert_rules (id, environment_id, name, description, severity, enabled,
condition_kind, condition, evaluation_interval_seconds, for_duration_seconds,
re_notify_minutes, notification_title_tmpl, notification_message_tmpl,
webhooks, next_evaluation_at, claimed_by, claimed_until, eval_state,
created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?::severity_enum, ?, ?::condition_kind_enum, ?::jsonb, ?, ?, ?, ?, ?, ?::jsonb,
?, ?, ?, ?::jsonb, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name, description = EXCLUDED.description,
severity = EXCLUDED.severity, enabled = EXCLUDED.enabled,
condition_kind = EXCLUDED.condition_kind, condition = EXCLUDED.condition,
evaluation_interval_seconds = EXCLUDED.evaluation_interval_seconds,
for_duration_seconds = EXCLUDED.for_duration_seconds,
re_notify_minutes = EXCLUDED.re_notify_minutes,
notification_title_tmpl = EXCLUDED.notification_title_tmpl,
notification_message_tmpl = EXCLUDED.notification_message_tmpl,
webhooks = EXCLUDED.webhooks, eval_state = EXCLUDED.eval_state,
updated_at = EXCLUDED.updated_at, updated_by = EXCLUDED.updated_by
""";
jdbc.update(sql,
r.id(), r.environmentId(), r.name(), r.description(),
r.severity().name(), r.enabled(), r.conditionKind().name(),
writeJson(r.condition()),
r.evaluationIntervalSeconds(), r.forDurationSeconds(), r.reNotifyMinutes(),
r.notificationTitleTmpl(), r.notificationMessageTmpl(),
writeJson(r.webhooks()),
Timestamp.from(r.nextEvaluationAt()),
r.claimedBy(),
r.claimedUntil() == null ? null : Timestamp.from(r.claimedUntil()),
writeJson(r.evalState()),
Timestamp.from(r.createdAt()), r.createdBy(),
Timestamp.from(r.updatedAt()), r.updatedBy());
return r;
}
@Override
public Optional<AlertRule> findById(UUID id) {
var list = jdbc.query("SELECT * FROM alert_rules WHERE id = ?", rowMapper(), id);
return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
}
@Override
public List<AlertRule> listByEnvironment(UUID environmentId) {
return jdbc.query(
"SELECT * FROM alert_rules WHERE environment_id = ? ORDER BY created_at DESC",
rowMapper(), environmentId);
}
@Override
public List<AlertRule> findAllByOutboundConnectionId(UUID connectionId) {
String sql = """
SELECT * FROM alert_rules
WHERE webhooks @> ?::jsonb
ORDER BY created_at DESC
""";
String predicate = "[{\"outboundConnectionId\":\"" + connectionId + "\"}]";
return jdbc.query(sql, rowMapper(), predicate);
}
@Override
public List<UUID> findRuleIdsByOutboundConnectionId(UUID connectionId) {
String sql = """
SELECT id FROM alert_rules
WHERE webhooks @> ?::jsonb
""";
String predicate = "[{\"outboundConnectionId\":\"" + connectionId + "\"}]";
return jdbc.queryForList(sql, UUID.class, predicate);
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM alert_rules WHERE id = ?", id);
}
@Override
public List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) {
String sql = """
UPDATE alert_rules
SET claimed_by = ?, claimed_until = now() + (? || ' seconds')::interval
WHERE id IN (
SELECT id FROM alert_rules
WHERE enabled = true
AND next_evaluation_at <= now()
AND (claimed_until IS NULL OR claimed_until < now())
ORDER BY next_evaluation_at
LIMIT ?
FOR UPDATE SKIP LOCKED
)
RETURNING *
""";
return jdbc.query(sql, rowMapper(), instanceId, claimTtlSeconds, batchSize);
}
@Override
public void releaseClaim(UUID ruleId, Instant nextEvaluationAt, Map<String, Object> evalState) {
jdbc.update("""
UPDATE alert_rules
SET claimed_by = NULL, claimed_until = NULL,
next_evaluation_at = ?, eval_state = ?::jsonb
WHERE id = ?
""",
Timestamp.from(nextEvaluationAt), writeJson(evalState), ruleId);
}
private RowMapper<AlertRule> rowMapper() {
return (rs, i) -> {
try {
ConditionKind kind = ConditionKind.valueOf(rs.getString("condition_kind"));
AlertCondition cond = om.readValue(rs.getString("condition"), AlertCondition.class);
List<WebhookBinding> webhooks = om.readValue(
rs.getString("webhooks"), new TypeReference<>() {});
Map<String, Object> evalState = om.readValue(
rs.getString("eval_state"), new TypeReference<>() {});
Timestamp cu = rs.getTimestamp("claimed_until");
return new AlertRule(
(UUID) rs.getObject("id"),
(UUID) rs.getObject("environment_id"),
rs.getString("name"),
rs.getString("description"),
AlertSeverity.valueOf(rs.getString("severity")),
rs.getBoolean("enabled"),
kind, cond,
rs.getInt("evaluation_interval_seconds"),
rs.getInt("for_duration_seconds"),
rs.getInt("re_notify_minutes"),
rs.getString("notification_title_tmpl"),
rs.getString("notification_message_tmpl"),
webhooks, List.of(),
rs.getTimestamp("next_evaluation_at").toInstant(),
rs.getString("claimed_by"),
cu == null ? null : cu.toInstant(),
evalState,
rs.getTimestamp("created_at").toInstant(),
rs.getString("created_by"),
rs.getTimestamp("updated_at").toInstant(),
rs.getString("updated_by"));
} catch (Exception e) {
throw new IllegalStateException("Failed to map alert_rules row", e);
}
};
}
private String writeJson(Object o) {
try {
return om.writeValueAsString(o);
} catch (Exception e) {
throw new IllegalStateException("Failed to serialize to JSON", e);
}
}
}

View File

@@ -0,0 +1,87 @@
package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.alerting.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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 PostgresAlertRuleRepositoryIT extends AbstractPostgresIT {
private PostgresAlertRuleRepository repo;
private UUID envId;
@BeforeEach
void setup() {
repo = new PostgresAlertRuleRepository(jdbcTemplate, new ObjectMapper());
envId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
envId, "test-env-" + UUID.randomUUID(), "Test Env");
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email) VALUES ('test-user', 'local', 'test@example.com')" +
" ON CONFLICT (user_id) DO NOTHING");
}
@AfterEach
void cleanup() {
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-user'");
}
@Test
void saveAndFindByIdRoundtrip() {
var rule = newRule(List.of());
repo.save(rule);
var found = repo.findById(rule.id()).orElseThrow();
assertThat(found.name()).isEqualTo(rule.name());
assertThat(found.condition()).isInstanceOf(AgentStateCondition.class);
assertThat(found.severity()).isEqualTo(AlertSeverity.WARNING);
assertThat(found.conditionKind()).isEqualTo(ConditionKind.AGENT_STATE);
}
@Test
void findRuleIdsByOutboundConnectionId() {
var connId = UUID.randomUUID();
var wb = new WebhookBinding(UUID.randomUUID(), connId, null, Map.of());
var rule = newRule(List.of(wb));
repo.save(rule);
List<UUID> ids = repo.findRuleIdsByOutboundConnectionId(connId);
assertThat(ids).containsExactly(rule.id());
assertThat(repo.findRuleIdsByOutboundConnectionId(UUID.randomUUID())).isEmpty();
}
@Test
void claimDueRulesAtomicSkipLocked() {
var rule = newRule(List.of());
repo.save(rule);
List<AlertRule> claimed = repo.claimDueRules("instance-A", 10, 30);
assertThat(claimed).hasSize(1);
// Second claimant sees nothing until first releases or TTL expires
List<AlertRule> second = repo.claimDueRules("instance-B", 10, 30);
assertThat(second).isEmpty();
}
private AlertRule newRule(List<WebhookBinding> webhooks) {
return new AlertRule(
UUID.randomUUID(), envId, "rule-" + UUID.randomUUID(), "desc",
AlertSeverity.WARNING, true, ConditionKind.AGENT_STATE,
new AgentStateCondition(new AlertScope(null, null, null), "DEAD", 60),
60, 0, 60, "t", "m", webhooks, List.of(),
Instant.now().minusSeconds(10), null, null, Map.of(),
Instant.now(), "test-user", Instant.now(), "test-user");
}
}