alerting(eval): PER_EXCHANGE composite cursor — monotone across same-ms exchanges

Tests:
- cursorMonotonicity_sameMillisecondExchanges_fireExactlyOncePerTick
- firstRun_boundedByRuleCreatedAt_notRetentionHistory
This commit is contained in:
hsiegeln
2026-04-22 16:11:01 +02:00
parent 0bad014811
commit 4acf0aeeff
2 changed files with 43 additions and 33 deletions

View File

@@ -166,7 +166,7 @@ class ExchangeMatchEvaluatorTest {
}
@Test
void perExchange_lastFiringCarriesNextCursor() {
void perExchange_batchCarriesNextCursorInEvalState() {
var condition = new ExchangeMatchCondition(
new AlertScope("orders", null, null),
new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
@@ -182,32 +182,32 @@ class ExchangeMatchEvaluatorTest {
EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
var batch = (EvalResult.Batch) r;
// last firing carries the _nextCursor key with the latest startTime
EvalResult.Firing last = batch.firings().get(batch.firings().size() - 1);
assertThat(last.context()).containsKey("_nextCursor");
assertThat(last.context().get("_nextCursor")).isEqualTo(t2);
// The batch carries the composite next-cursor in nextEvalState under "lastExchangeCursor"
assertThat(batch.nextEvalState()).containsKey("lastExchangeCursor");
assertThat(batch.nextEvalState().get("lastExchangeCursor"))
.isEqualTo(t2.toString() + "|ex-2");
}
@Test
void perExchange_usesLastExchangeTsFromEvalState() {
void perExchange_usesLastExchangeCursorFromEvalState() {
var condition = new ExchangeMatchCondition(
new AlertScope("orders", null, null),
new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
FireMode.PER_EXCHANGE, null, null, 60);
Instant cursor = NOW.minusSeconds(120);
var rule = ruleWith(condition, Map.of("lastExchangeTs", cursor.toString()));
var rule = ruleWith(condition, Map.of("lastExchangeCursor", cursor.toString() + "|ex-prev"));
when(searchIndex.search(any())).thenReturn(SearchResult.empty(0, 50));
eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache()));
// Verify the search request used the cursor as the lower-bound
// Verify the search request used the cursor tuple: timeFrom + afterExecutionId
ArgumentCaptor<com.cameleer.server.core.search.SearchRequest> captor =
ArgumentCaptor.forClass(com.cameleer.server.core.search.SearchRequest.class);
verify(searchIndex).search(captor.capture());
// timeFrom should be the cursor value
assertThat(captor.getValue().timeFrom()).isEqualTo(cursor);
assertThat(captor.getValue().afterExecutionId()).isEqualTo("ex-prev");
}
@Test
@@ -241,6 +241,7 @@ class ExchangeMatchEvaluatorTest {
EvalResult r3 = eval.evaluate(condition, advanced,
new EvalContext("default", t.plusSeconds(3), new TickCache()));
assertThat(((EvalResult.Batch) r3).firings()).hasSize(1);
assertThat(((EvalResult.Batch) r3).nextEvalState()).containsKey("lastExchangeCursor");
}
@Test