alerting(eval): PER_EXCHANGE composite cursor — monotone across same-ms exchanges
Tests: - cursorMonotonicity_sameMillisecondExchanges_fireExactlyOncePerTick - firstRun_boundedByRuleCreatedAt_notRetentionHistory
This commit is contained in:
@@ -14,6 +14,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -85,19 +86,29 @@ public class ExchangeMatchEvaluator implements ConditionEvaluator<ExchangeMatchC
|
||||
String routeId = c.scope() != null ? c.scope().routeId() : null;
|
||||
ExchangeMatchCondition.ExchangeFilter filter = c.filter();
|
||||
|
||||
// Resolve cursor from evalState
|
||||
Instant cursor = null;
|
||||
Object raw = rule.evalState().get("lastExchangeTs");
|
||||
// Resolve composite cursor: (startTime, executionId)
|
||||
Instant cursorTs;
|
||||
String cursorId;
|
||||
Object raw = rule.evalState().get("lastExchangeCursor");
|
||||
if (raw instanceof String s && !s.isBlank()) {
|
||||
try { cursor = Instant.parse(s); } catch (Exception ignored) {}
|
||||
} else if (raw instanceof Instant i) {
|
||||
cursor = i;
|
||||
int pipe = s.indexOf('|');
|
||||
if (pipe < 0) {
|
||||
// Malformed — treat as first-run
|
||||
cursorTs = rule.createdAt();
|
||||
cursorId = "";
|
||||
} else {
|
||||
cursorTs = Instant.parse(s.substring(0, pipe));
|
||||
cursorId = s.substring(pipe + 1);
|
||||
}
|
||||
} else {
|
||||
// First run — bounded by rule.createdAt, empty executionId so any real id sorts after it
|
||||
cursorTs = rule.createdAt();
|
||||
cursorId = "";
|
||||
}
|
||||
|
||||
// Build SearchRequest — use cursor as timeFrom so we only see exchanges after last run
|
||||
var req = new SearchRequest(
|
||||
filter != null ? filter.status() : null,
|
||||
cursor, // timeFrom = cursor (or null for first run)
|
||||
cursorTs, // timeFrom
|
||||
ctx.now(), // timeTo
|
||||
null, null, null, // durationMin/Max, correlationId
|
||||
null, null, null, null, // text variants
|
||||
@@ -110,24 +121,26 @@ public class ExchangeMatchEvaluator implements ConditionEvaluator<ExchangeMatchC
|
||||
50,
|
||||
"startTime",
|
||||
"asc", // asc so we process oldest first
|
||||
null, // afterExecutionId (wired in Task 1.5)
|
||||
cursorId.isEmpty() ? null : cursorId, // afterExecutionId — null on first run enables >=
|
||||
envSlug
|
||||
);
|
||||
|
||||
SearchResult<ExecutionSummary> result = searchIndex.search(req);
|
||||
List<ExecutionSummary> matches = result.data();
|
||||
|
||||
if (matches.isEmpty()) return new EvalResult.Batch(List.of(), Map.of());
|
||||
if (matches.isEmpty()) return EvalResult.Batch.empty();
|
||||
|
||||
// Find the latest startTime across all matches — becomes the next cursor
|
||||
Instant latestTs = matches.stream()
|
||||
.map(ExecutionSummary::startTime)
|
||||
.max(Instant::compareTo)
|
||||
.orElse(ctx.now());
|
||||
// Ensure deterministic ordering for cursor advance
|
||||
matches = new ArrayList<>(matches);
|
||||
matches.sort(Comparator
|
||||
.comparing(ExecutionSummary::startTime)
|
||||
.thenComparing(ExecutionSummary::executionId));
|
||||
|
||||
ExecutionSummary last = matches.get(matches.size() - 1);
|
||||
String nextCursorSerialized = last.startTime().toString() + "|" + last.executionId();
|
||||
|
||||
List<EvalResult.Firing> firings = new ArrayList<>();
|
||||
for (int i = 0; i < matches.size(); i++) {
|
||||
ExecutionSummary ex = matches.get(i);
|
||||
for (ExecutionSummary ex : matches) {
|
||||
Map<String, Object> ctx2 = new HashMap<>();
|
||||
ctx2.put("exchange", Map.of(
|
||||
"id", ex.executionId(),
|
||||
@@ -136,15 +149,11 @@ public class ExchangeMatchEvaluator implements ConditionEvaluator<ExchangeMatchC
|
||||
"startTime", ex.startTime() == null ? "" : ex.startTime().toString()
|
||||
));
|
||||
ctx2.put("app", Map.of("slug", ex.applicationId() == null ? "" : ex.applicationId()));
|
||||
|
||||
// Attach the next-cursor to the last firing so the job can extract it
|
||||
if (i == matches.size() - 1) {
|
||||
ctx2.put("_nextCursor", latestTs);
|
||||
}
|
||||
|
||||
firings.add(new EvalResult.Firing(1.0, null, ctx2));
|
||||
}
|
||||
|
||||
return new EvalResult.Batch(firings, Map.of());
|
||||
Map<String, Object> nextEvalState = new HashMap<>(rule.evalState());
|
||||
nextEvalState.put("lastExchangeCursor", nextCursorSerialized);
|
||||
return new EvalResult.Batch(firings, nextEvalState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user