feat(alerting): Postgres repositories for silences, notifications, reads
PostgresAlertSilenceRepository: save/findById roundtrip, listActive (BETWEEN starts_at AND ends_at), listByEnvironment, delete. JSONB SilenceMatcher via ObjectMapper. PostgresAlertNotificationRepository: save/findById, listForInstance, claimDueNotifications (UPDATE...RETURNING with FOR UPDATE SKIP LOCKED), markDelivered, scheduleRetry (bumps attempts + next_attempt_at), markFailed, deleteSettledBefore (DELIVERED+FAILED rows older than cutoff). JSONB payload. PostgresAlertReadRepository: markRead (ON CONFLICT DO NOTHING idempotent), bulkMarkRead (iterates, handles empty list without error). 16 IT scenarios across 3 classes, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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 PostgresAlertNotificationRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
private PostgresAlertNotificationRepository repo;
|
||||
private UUID envId;
|
||||
private UUID instanceId;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
repo = new PostgresAlertNotificationRepository(jdbcTemplate, new ObjectMapper());
|
||||
envId = UUID.randomUUID();
|
||||
instanceId = UUID.randomUUID();
|
||||
UUID ruleId = 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 ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, 'rule', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
|
||||
ruleId, envId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
|
||||
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
|
||||
"now(), '{}'::jsonb, 'title', 'msg')",
|
||||
instanceId, ruleId, envId);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id = ?", instanceId);
|
||||
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindByIdRoundtrip() {
|
||||
var n = newNotification();
|
||||
repo.save(n);
|
||||
|
||||
var found = repo.findById(n.id()).orElseThrow();
|
||||
assertThat(found.id()).isEqualTo(n.id());
|
||||
assertThat(found.status()).isEqualTo(NotificationStatus.PENDING);
|
||||
assertThat(found.alertInstanceId()).isEqualTo(instanceId);
|
||||
assertThat(found.payload()).containsKey("key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimDueNotifications_claimsAndSkipsLocked() {
|
||||
var n = newNotification();
|
||||
repo.save(n);
|
||||
|
||||
var claimed = repo.claimDueNotifications("worker-1", 10, 30);
|
||||
assertThat(claimed).hasSize(1);
|
||||
assertThat(claimed.get(0).claimedBy()).isEqualTo("worker-1");
|
||||
|
||||
// second claimant sees nothing
|
||||
var second = repo.claimDueNotifications("worker-2", 10, 30);
|
||||
assertThat(second).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markDelivered_setsStatusAndDeliveredAt() {
|
||||
var n = newNotification();
|
||||
repo.save(n);
|
||||
|
||||
repo.markDelivered(n.id(), 200, "OK", Instant.now());
|
||||
|
||||
var found = repo.findById(n.id()).orElseThrow();
|
||||
assertThat(found.status()).isEqualTo(NotificationStatus.DELIVERED);
|
||||
assertThat(found.lastResponseStatus()).isEqualTo(200);
|
||||
assertThat(found.deliveredAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void scheduleRetry_bumpsAttemptsAndNextAttempt() {
|
||||
var n = newNotification();
|
||||
repo.save(n);
|
||||
|
||||
Instant nextAttempt = Instant.now().plusSeconds(60);
|
||||
repo.scheduleRetry(n.id(), nextAttempt, 503, "Service Unavailable");
|
||||
|
||||
var found = repo.findById(n.id()).orElseThrow();
|
||||
assertThat(found.attempts()).isEqualTo(1);
|
||||
assertThat(found.status()).isEqualTo(NotificationStatus.PENDING); // still pending
|
||||
assertThat(found.lastResponseStatus()).isEqualTo(503);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markFailed_setsStatusFailed() {
|
||||
var n = newNotification();
|
||||
repo.save(n);
|
||||
|
||||
repo.markFailed(n.id(), 400, "Bad Request");
|
||||
|
||||
var found = repo.findById(n.id()).orElseThrow();
|
||||
assertThat(found.status()).isEqualTo(NotificationStatus.FAILED);
|
||||
assertThat(found.lastResponseStatus()).isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSettledBefore_deletesDeliveredAndFailed() {
|
||||
var pending = newNotification();
|
||||
var delivered = newNotification();
|
||||
var failed = newNotification();
|
||||
|
||||
repo.save(pending);
|
||||
repo.save(delivered);
|
||||
repo.save(failed);
|
||||
|
||||
repo.markDelivered(delivered.id(), 200, "OK", Instant.now().minusSeconds(3600));
|
||||
repo.markFailed(failed.id(), 500, "Error");
|
||||
|
||||
// deleteSettledBefore uses created_at — use future cutoff to delete all settled
|
||||
repo.deleteSettledBefore(Instant.now().plusSeconds(60));
|
||||
|
||||
assertThat(repo.findById(pending.id())).isPresent();
|
||||
assertThat(repo.findById(delivered.id())).isEmpty();
|
||||
assertThat(repo.findById(failed.id())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listForInstance_returnsAll() {
|
||||
repo.save(newNotification());
|
||||
repo.save(newNotification());
|
||||
|
||||
var list = repo.listForInstance(instanceId);
|
||||
assertThat(list).hasSize(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private AlertNotification newNotification() {
|
||||
return new AlertNotification(
|
||||
UUID.randomUUID(), instanceId,
|
||||
UUID.randomUUID(), null,
|
||||
NotificationStatus.PENDING, 0,
|
||||
Instant.now().minusSeconds(10),
|
||||
null, null,
|
||||
null, null,
|
||||
Map.of("key", "value"),
|
||||
null, Instant.now());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
class PostgresAlertReadRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
private PostgresAlertReadRepository repo;
|
||||
private UUID envId;
|
||||
private UUID instanceId1;
|
||||
private UUID instanceId2;
|
||||
private UUID instanceId3;
|
||||
private final String userId = "read-user-" + UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
repo = new PostgresAlertReadRepository(jdbcTemplate);
|
||||
envId = UUID.randomUUID();
|
||||
instanceId1 = UUID.randomUUID();
|
||||
instanceId2 = UUID.randomUUID();
|
||||
instanceId3 = UUID.randomUUID();
|
||||
UUID ruleId = 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 ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES (?, 'local', ?) ON CONFLICT (user_id) DO NOTHING",
|
||||
userId, userId + "@example.com");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, 'rule', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
|
||||
ruleId, envId);
|
||||
|
||||
for (UUID id : List.of(instanceId1, instanceId2, instanceId3)) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
|
||||
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
|
||||
"now(), '{}'::jsonb, 'title', 'msg')",
|
||||
id, ruleId, envId);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM alert_reads WHERE user_id = ?", userId);
|
||||
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
|
||||
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 = ?", userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markRead_insertsReadRecord() {
|
||||
repo.markRead(userId, instanceId1);
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
|
||||
Integer.class, userId, instanceId1);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markRead_isIdempotent() {
|
||||
repo.markRead(userId, instanceId1);
|
||||
// second call should not throw
|
||||
assertThatCode(() -> repo.markRead(userId, instanceId1)).doesNotThrowAnyException();
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
|
||||
Integer.class, userId, instanceId1);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMarkRead_marksMultiple() {
|
||||
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2, instanceId3));
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
|
||||
Integer.class, userId);
|
||||
assertThat(count).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMarkRead_emptyListDoesNotThrow() {
|
||||
assertThatCode(() -> repo.bulkMarkRead(userId, List.of())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkMarkRead_isIdempotent() {
|
||||
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2));
|
||||
assertThatCode(() -> repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2)))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
int count = jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
|
||||
Integer.class, userId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.core.alerting.AlertSilence;
|
||||
import com.cameleer.server.core.alerting.SilenceMatcher;
|
||||
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.time.temporal.ChronoUnit;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PostgresAlertSilenceRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
private PostgresAlertSilenceRepository repo;
|
||||
private UUID envId;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
repo = new PostgresAlertSilenceRepository(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 ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindByIdRoundtrip() {
|
||||
var silence = newSilence(Instant.now().minusSeconds(60), Instant.now().plusSeconds(3600));
|
||||
repo.save(silence);
|
||||
|
||||
var found = repo.findById(silence.id()).orElseThrow();
|
||||
assertThat(found.id()).isEqualTo(silence.id());
|
||||
assertThat(found.environmentId()).isEqualTo(envId);
|
||||
assertThat(found.reason()).isEqualTo("test reason");
|
||||
assertThat(found.matcher()).isNotNull();
|
||||
assertThat(found.matcher().isWildcard()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listActive_returnsOnlyCurrentSilences() {
|
||||
Instant now = Instant.now();
|
||||
var active = newSilence(now.minusSeconds(60), now.plusSeconds(3600));
|
||||
var future = newSilence(now.plusSeconds(60), now.plusSeconds(7200));
|
||||
var past = newSilence(now.minusSeconds(7200), now.minusSeconds(60));
|
||||
|
||||
repo.save(active);
|
||||
repo.save(future);
|
||||
repo.save(past);
|
||||
|
||||
var result = repo.listActive(envId, now);
|
||||
assertThat(result).extracting(AlertSilence::id)
|
||||
.containsExactly(active.id())
|
||||
.doesNotContain(future.id(), past.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_removesRow() {
|
||||
var silence = newSilence(Instant.now().minusSeconds(60), Instant.now().plusSeconds(3600));
|
||||
repo.save(silence);
|
||||
assertThat(repo.findById(silence.id())).isPresent();
|
||||
|
||||
repo.delete(silence.id());
|
||||
|
||||
assertThat(repo.findById(silence.id())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listByEnvironment_returnsAll() {
|
||||
repo.save(newSilence(Instant.now().minusSeconds(60), Instant.now().plusSeconds(3600)));
|
||||
repo.save(newSilence(Instant.now().minusSeconds(30), Instant.now().plusSeconds(1800)));
|
||||
|
||||
var list = repo.listByEnvironment(envId);
|
||||
assertThat(list).hasSize(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private AlertSilence newSilence(Instant startsAt, Instant endsAt) {
|
||||
return new AlertSilence(
|
||||
UUID.randomUUID(), envId,
|
||||
new SilenceMatcher(null, null, null, null, null),
|
||||
"test reason", startsAt, endsAt, "sys-user", Instant.now());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user