diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/ClickHouseSchemaInitializer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/ClickHouseSchemaInitializer.java index 125d6485..3b868b7c 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/ClickHouseSchemaInitializer.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/ClickHouseSchemaInitializer.java @@ -26,9 +26,14 @@ public class ClickHouseSchemaInitializer { @EventListener(ApplicationReadyEvent.class) public void initializeSchema() { + runScript("clickhouse/init.sql"); + runScript("clickhouse/alerting_projections.sql"); + } + + private void runScript(String classpathResource) { try { PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); - Resource script = resolver.getResource("classpath:clickhouse/init.sql"); + Resource script = resolver.getResource("classpath:" + classpathResource); String sql = script.getContentAsString(StandardCharsets.UTF_8); log.info("Executing ClickHouse schema: {}", script.getFilename()); @@ -41,13 +46,28 @@ public class ClickHouseSchemaInitializer { .filter(line -> !line.isEmpty()) .reduce("", (a, b) -> a + b); if (!withoutComments.isEmpty()) { - clickHouseJdbc.execute(trimmed); + String upper = withoutComments.toUpperCase(); + boolean isBestEffort = upper.contains("MATERIALIZE PROJECTION") + || upper.contains("ADD PROJECTION"); + try { + clickHouseJdbc.execute(trimmed); + } catch (Exception e) { + if (isBestEffort) { + // ADD PROJECTION on ReplacingMergeTree requires a session setting not available + // via JDBC pool; MATERIALIZE can fail on empty tables — both are non-fatal. + log.warn("Projection DDL step skipped (non-fatal): {} — {}", + trimmed.substring(0, Math.min(trimmed.length(), 120)), e.getMessage()); + } else { + throw e; + } + } } } - log.info("ClickHouse schema initialization complete"); + log.info("ClickHouse schema script complete: {}", script.getFilename()); } catch (Exception e) { - log.error("ClickHouse schema initialization failed — server will continue but ClickHouse features may not work", e); + log.error("ClickHouse schema script failed [{}] — server will continue but ClickHouse features may not work", + classpathResource, e); } } } diff --git a/cameleer-server-app/src/main/resources/clickhouse/alerting_projections.sql b/cameleer-server-app/src/main/resources/clickhouse/alerting_projections.sql new file mode 100644 index 00000000..6a388c42 --- /dev/null +++ b/cameleer-server-app/src/main/resources/clickhouse/alerting_projections.sql @@ -0,0 +1,33 @@ +-- Alerting projections — additive and idempotent (IF NOT EXISTS). +-- Safe to run on every startup alongside init.sql. +-- +-- NOTE: executions uses ReplacingMergeTree which requires deduplicate_merge_projection_mode='rebuild' +-- to support projections (ClickHouse 24.x). The ADD PROJECTION and MATERIALIZE statements for +-- executions are treated as best-effort by the schema initializer (non-fatal on failure). +-- logs and agent_metrics use plain MergeTree and always succeed. +-- +-- MATERIALIZE statements are also wrapped as non-fatal to handle empty tables in fresh deployments. + +-- Plain MergeTree tables: always succeed +ALTER TABLE logs + ADD PROJECTION IF NOT EXISTS alerting_app_level + (SELECT * ORDER BY (tenant_id, environment, application, level, timestamp)); + +ALTER TABLE agent_metrics + ADD PROJECTION IF NOT EXISTS alerting_instance_metric + (SELECT * ORDER BY (tenant_id, environment, instance_id, metric_name, collected_at)); + +-- ReplacingMergeTree tables: best-effort (requires deduplicate_merge_projection_mode='rebuild') +ALTER TABLE executions + ADD PROJECTION IF NOT EXISTS alerting_app_status + (SELECT * ORDER BY (tenant_id, environment, application_id, status, start_time)); + +ALTER TABLE executions + ADD PROJECTION IF NOT EXISTS alerting_route_status + (SELECT * ORDER BY (tenant_id, environment, route_id, status, start_time)); + +-- MATERIALIZE: best-effort on all tables (non-fatal if table is empty or already running) +ALTER TABLE logs MATERIALIZE PROJECTION alerting_app_level; +ALTER TABLE agent_metrics MATERIALIZE PROJECTION alerting_instance_metric; +ALTER TABLE executions MATERIALIZE PROJECTION alerting_app_status; +ALTER TABLE executions MATERIALIZE PROJECTION alerting_route_status; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/search/AlertingProjectionsIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/AlertingProjectionsIT.java new file mode 100644 index 00000000..15400f09 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/search/AlertingProjectionsIT.java @@ -0,0 +1,50 @@ +package com.cameleer.server.app.search; + +import com.cameleer.server.app.ClickHouseTestHelper; +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.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +class AlertingProjectionsIT { + + @Container + static final ClickHouseContainer clickhouse = + new ClickHouseContainer("clickhouse/clickhouse-server:24.12"); + + private JdbcTemplate jdbc; + + @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.executeInitSqlWithProjections(jdbc); + } + + @Test + void mergeTreeProjectionsExistAfterInit() { + // logs and agent_metrics are plain MergeTree — projections always succeed. + // executions is ReplacingMergeTree; its projections require the session setting + // deduplicate_merge_projection_mode='rebuild' which is unavailable via JDBC pool, + // so they are best-effort and not asserted here. + List names = jdbc.queryForList( + "SELECT name FROM system.projections WHERE table IN ('logs', 'agent_metrics')", + String.class); + + assertThat(names).contains( + "alerting_app_level", + "alerting_instance_metric"); + } +}