diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingProperties.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingProperties.java index 66c74803..b7020713 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingProperties.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingProperties.java @@ -16,7 +16,8 @@ public record AlertingProperties( Integer eventRetentionDays, Integer notificationRetentionDays, Integer webhookTimeoutMs, - Integer webhookMaxAttempts) { + Integer webhookMaxAttempts, + Integer perExchangeDeployBacklogCapSeconds) { public int effectiveEvaluatorTickIntervalMs() { int raw = evaluatorTickIntervalMs == null ? 5000 : evaluatorTickIntervalMs; @@ -70,4 +71,9 @@ public record AlertingProperties( public int cbCooldownSeconds() { return circuitBreakerCooldownSeconds == null ? 60 : circuitBreakerCooldownSeconds; } + + public int effectivePerExchangeDeployBacklogCapSeconds() { + // Default 24 h. Zero or negative = disabled (no clamp — first-run uses rule.createdAt as today). + return perExchangeDeployBacklogCapSeconds == null ? 86_400 : perExchangeDeployBacklogCapSeconds; + } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java index e05da07e..d3a635aa 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java @@ -1,5 +1,6 @@ package com.cameleer.server.app.alerting.eval; +import com.cameleer.server.app.alerting.config.AlertingProperties; import com.cameleer.server.app.search.ClickHouseSearchIndex; import com.cameleer.server.core.alerting.AlertMatchSpec; import com.cameleer.server.core.alerting.AlertRule; @@ -24,10 +25,14 @@ public class ExchangeMatchEvaluator implements ConditionEvaluator + * Cap ≤ 0 disables the clamp (first-run falls back to {@code rule.createdAt()} verbatim). + * Applied only on first-run / malformed-cursor paths — the normal-advance path is + * intentionally unaffected so legitimate missed ticks are not silently skipped. + */ + private Instant firstRunCursorTs(AlertRule rule, EvalContext ctx) { + Instant cursorTs = rule.createdAt(); + int capSeconds = alertingProperties.effectivePerExchangeDeployBacklogCapSeconds(); + if (capSeconds > 0) { + Instant capFloor = ctx.now().minusSeconds(capSeconds); + if (cursorTs == null || cursorTs.isBefore(capFloor)) { + cursorTs = capFloor; + } + } + return cursorTs; + } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java index 33bb64f4..b923e725 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java @@ -1,5 +1,6 @@ package com.cameleer.server.app.alerting.eval; +import com.cameleer.server.app.alerting.config.AlertingProperties; import com.cameleer.server.app.search.ClickHouseSearchIndex; import com.cameleer.server.core.alerting.*; import com.cameleer.server.core.runtime.Environment; @@ -36,7 +37,9 @@ class ExchangeMatchEvaluatorTest { void setUp() { searchIndex = mock(ClickHouseSearchIndex.class); envRepo = mock(EnvironmentRepository.class); - eval = new ExchangeMatchEvaluator(searchIndex, envRepo); + AlertingProperties props = new AlertingProperties( + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props); var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env)); @@ -264,6 +267,39 @@ class ExchangeMatchEvaluatorTest { assertThat(((EvalResult.Batch) r).firings()).hasSize(1); } + @Test + void firstRun_clampsCursorToDeployBacklogCap_whenRuleCreatedLongAgo() { + // Rule created 7 days ago, cap default is 24h; expect timeFrom to be now - 24h, not rule.createdAt. + Instant now = Instant.parse("2026-04-22T12:00:00Z"); + Instant createdLongAgo = now.minus(Duration.ofDays(7)); + Instant expectedClampFloor = now.minusSeconds(86_400); // 24h + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchRequest.class); + when(searchIndex.search(cap.capture())).thenReturn(new SearchResult<>(List.of(), 0L, 0, 50)); + + ExchangeMatchCondition condition = perExchangeCondition(); + AlertRule rule = ruleWith(condition, Map.of(), createdLongAgo); + eval.evaluate(condition, rule, new EvalContext("default", now, new TickCache())); + + SearchRequest req = cap.getValue(); + assertThat(req.timeFrom()).isEqualTo(expectedClampFloor); + } + + @Test + void firstRun_usesCreatedAt_whenWithinDeployBacklogCap() { + Instant now = Instant.parse("2026-04-22T12:00:00Z"); + Instant createdRecent = now.minus(Duration.ofHours(1)); // 1h < 24h cap + + ArgumentCaptor cap = ArgumentCaptor.forClass(SearchRequest.class); + when(searchIndex.search(cap.capture())).thenReturn(new SearchResult<>(List.of(), 0L, 0, 50)); + + ExchangeMatchCondition condition = perExchangeCondition(); + AlertRule rule = ruleWith(condition, Map.of(), createdRecent); + eval.evaluate(condition, rule, new EvalContext("default", now, new TickCache())); + + assertThat(cap.getValue().timeFrom()).isEqualTo(createdRecent); + } + @Test void kindIsExchangeMatch() { assertThat(eval.kind()).isEqualTo(ConditionKind.EXCHANGE_MATCH); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/WebhookDispatcherIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/WebhookDispatcherIT.java index b26a4616..a45a81db 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/WebhookDispatcherIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/WebhookDispatcherIT.java @@ -50,7 +50,7 @@ class WebhookDispatcherIT { new ApacheOutboundHttpClientFactory(props, new SslContextBuilder()), cipher, new MustacheRenderer(), - new AlertingProperties(null, null, null, null, null, null, null, null, null, null, null, null, null), + new AlertingProperties(null, null, null, null, null, null, null, null, null, null, null, null, null, null), new ObjectMapper() ); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/retention/AlertingRetentionJobIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/retention/AlertingRetentionJobIT.java index 32c865e3..93ea1977 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/retention/AlertingRetentionJobIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/retention/AlertingRetentionJobIT.java @@ -167,7 +167,7 @@ class AlertingRetentionJobIT extends AbstractPostgresIT { // effectiveEventRetentionDays = 90, effectiveNotificationRetentionDays = 30 new com.cameleer.server.app.alerting.config.AlertingProperties( null, null, null, null, null, null, null, null, null, - 90, 30, null, null), + 90, 30, null, null, null), instanceRepo, notificationRepo, fixedClock);