feat(alerting): countExecutionsForAlerting for exchange-match evaluator

Adds AlertMatchSpec record (core) and ClickHouseSearchIndex.countExecutionsForAlerting —
no FINAL, no text subqueries. Filters by tenant, env, app, route, status, time window,
and optional after-cursor. Attributes (JSON string column) use inlined JSONExtractString
key literals since ClickHouse JDBC does not bind ? placeholders inside JSON functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:18:49 +02:00
parent 44e91ccdb5
commit 7b79d3aa64
3 changed files with 220 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
package com.cameleer.server.app.search;
import com.cameleer.server.app.ClickHouseTestHelper;
import com.cameleer.server.app.storage.ClickHouseExecutionStore;
import com.cameleer.server.core.alerting.AlertMatchSpec;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseSearchIndexAlertingCountIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseSearchIndex searchIndex;
private ClickHouseExecutionStore store;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE executions");
jdbc.execute("TRUNCATE TABLE processor_executions");
store = new ClickHouseExecutionStore("default", jdbc);
searchIndex = new ClickHouseSearchIndex("default", jdbc);
}
private MergedExecution exec(String id, String status, String appId, String routeId, String attributes, Instant start) {
return new MergedExecution(
"default", 1L, id, routeId, "agent-1", appId, "prod",
status, "", "exchange-" + id,
start, start.plusMillis(100), 100L,
"", "", "", "", "", "", // errorMessage..rootCauseMessage
"", "FULL", // diagramContentHash, engineLevel
"", "", "", "", "", "", // inputBody, outputBody, inputHeaders, outputHeaders, inputProperties, outputProperties
attributes, // attributes (JSON string)
"", "", // traceId, spanId
false, false,
null, null
);
}
@Test
void countExecutionsForAlerting_byStatus() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
store.insertExecutionBatch(List.of(
exec("e1", "FAILED", "orders", "route-a", "{}", base),
exec("e2", "FAILED", "orders", "route-a", "{}", base.plusSeconds(1)),
exec("e3", "COMPLETED", "orders", "route-a", "{}", base.plusSeconds(2))
));
AlertMatchSpec spec = new AlertMatchSpec(
"default", "prod", "orders", null, "FAILED",
null,
base.minusSeconds(10), base.plusSeconds(60), null);
assertThat(searchIndex.countExecutionsForAlerting(spec)).isEqualTo(2);
}
@Test
void countExecutionsForAlerting_byRouteId() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
store.insertExecutionBatch(List.of(
exec("e1", "FAILED", "orders", "route-a", "{}", base),
exec("e2", "FAILED", "orders", "route-b", "{}", base.plusSeconds(1)),
exec("e3", "FAILED", "orders", "route-a", "{}", base.plusSeconds(2))
));
AlertMatchSpec spec = new AlertMatchSpec(
"default", "prod", null, "route-a", null,
null,
base.minusSeconds(10), base.plusSeconds(60), null);
assertThat(searchIndex.countExecutionsForAlerting(spec)).isEqualTo(2);
}
@Test
void countExecutionsForAlerting_withAttributes() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
store.insertExecutionBatch(List.of(
exec("e1", "FAILED", "orders", "route-a", "{\"region\":\"eu\",\"priority\":\"high\"}", base),
exec("e2", "FAILED", "orders", "route-a", "{\"region\":\"us\"}", base.plusSeconds(1)),
exec("e3", "FAILED", "orders", "route-a", "{}", base.plusSeconds(2))
));
AlertMatchSpec spec = new AlertMatchSpec(
"default", "prod", null, null, null,
Map.of("region", "eu"),
base.minusSeconds(10), base.plusSeconds(60), null);
assertThat(searchIndex.countExecutionsForAlerting(spec)).isEqualTo(1);
}
@Test
void countExecutionsForAlerting_afterCursor() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
store.insertExecutionBatch(List.of(
exec("e1", "FAILED", "orders", "route-a", "{}", base),
exec("e2", "FAILED", "orders", "route-a", "{}", base.plusSeconds(5)),
exec("e3", "FAILED", "orders", "route-a", "{}", base.plusSeconds(10))
));
// after = base+2s, so only e2 and e3 should count
AlertMatchSpec spec = new AlertMatchSpec(
"default", "prod", null, null, null,
null,
base.minusSeconds(1), base.plusSeconds(60), base.plusSeconds(2));
assertThat(searchIndex.countExecutionsForAlerting(spec)).isEqualTo(2);
}
@Test
void countExecutionsForAlerting_noMatchReturnsZero() {
Instant base = Instant.parse("2026-04-19T10:00:00Z");
store.insertExecutionBatch(List.of(
exec("e1", "COMPLETED", "orders", "route-a", "{}", base)
));
AlertMatchSpec spec = new AlertMatchSpec(
"default", "prod", null, null, "FAILED",
null,
base.minusSeconds(10), base.plusSeconds(60), null);
assertThat(searchIndex.countExecutionsForAlerting(spec)).isZero();
}
}