feat(01-02): add IngestionService, ClickHouse repositories, and flush scheduler
- IngestionService routes data to WriteBuffer instances (core module, plain class) - ClickHouseExecutionRepository: batch insert with parallel processor arrays - ClickHouseDiagramRepository: JSON storage with SHA-256 content-hash dedup - ClickHouseMetricsRepository: batch insert for agent_metrics table - ClickHouseFlushScheduler: scheduled drain with SmartLifecycle shutdown flush - IngestionBeanConfig: wires WriteBuffer and IngestionService beans Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.common.graph.RouteGraph;
|
||||||
|
import com.cameleer3.common.model.RouteExecution;
|
||||||
|
import com.cameleer3.server.core.ingestion.IngestionService;
|
||||||
|
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||||
|
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the write buffer and ingestion service beans.
|
||||||
|
* <p>
|
||||||
|
* The {@link WriteBuffer} instances are shared between the
|
||||||
|
* {@link IngestionService} (producer side) and the flush scheduler (consumer side).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class IngestionBeanConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public WriteBuffer<RouteExecution> executionBuffer(IngestionConfig config) {
|
||||||
|
return new WriteBuffer<>(config.getBufferCapacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public WriteBuffer<RouteGraph> diagramBuffer(IngestionConfig config) {
|
||||||
|
return new WriteBuffer<>(config.getBufferCapacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public WriteBuffer<MetricsSnapshot> metricsBuffer(IngestionConfig config) {
|
||||||
|
return new WriteBuffer<>(config.getBufferCapacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public IngestionService ingestionService(WriteBuffer<RouteExecution> executionBuffer,
|
||||||
|
WriteBuffer<RouteGraph> diagramBuffer,
|
||||||
|
WriteBuffer<MetricsSnapshot> metricsBuffer) {
|
||||||
|
return new IngestionService(executionBuffer, diagramBuffer, metricsBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.cameleer3.server.app.ingestion;
|
||||||
|
|
||||||
|
import com.cameleer3.common.graph.RouteGraph;
|
||||||
|
import com.cameleer3.common.model.RouteExecution;
|
||||||
|
import com.cameleer3.server.app.config.IngestionConfig;
|
||||||
|
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||||
|
import com.cameleer3.server.core.storage.DiagramRepository;
|
||||||
|
import com.cameleer3.server.core.storage.ExecutionRepository;
|
||||||
|
import com.cameleer3.server.core.storage.MetricsRepository;
|
||||||
|
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.SmartLifecycle;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled task that drains the write buffers and batch-inserts into ClickHouse.
|
||||||
|
* <p>
|
||||||
|
* Implements {@link SmartLifecycle} to ensure all remaining buffered data is
|
||||||
|
* flushed on application shutdown.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ClickHouseFlushScheduler implements SmartLifecycle {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClickHouseFlushScheduler.class);
|
||||||
|
|
||||||
|
private final WriteBuffer<RouteExecution> executionBuffer;
|
||||||
|
private final WriteBuffer<RouteGraph> diagramBuffer;
|
||||||
|
private final WriteBuffer<MetricsSnapshot> metricsBuffer;
|
||||||
|
private final ExecutionRepository executionRepository;
|
||||||
|
private final DiagramRepository diagramRepository;
|
||||||
|
private final MetricsRepository metricsRepository;
|
||||||
|
private final int batchSize;
|
||||||
|
|
||||||
|
private volatile boolean running = false;
|
||||||
|
|
||||||
|
public ClickHouseFlushScheduler(WriteBuffer<RouteExecution> executionBuffer,
|
||||||
|
WriteBuffer<RouteGraph> diagramBuffer,
|
||||||
|
WriteBuffer<MetricsSnapshot> metricsBuffer,
|
||||||
|
ExecutionRepository executionRepository,
|
||||||
|
DiagramRepository diagramRepository,
|
||||||
|
MetricsRepository metricsRepository,
|
||||||
|
IngestionConfig config) {
|
||||||
|
this.executionBuffer = executionBuffer;
|
||||||
|
this.diagramBuffer = diagramBuffer;
|
||||||
|
this.metricsBuffer = metricsBuffer;
|
||||||
|
this.executionRepository = executionRepository;
|
||||||
|
this.diagramRepository = diagramRepository;
|
||||||
|
this.metricsRepository = metricsRepository;
|
||||||
|
this.batchSize = config.getBatchSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${ingestion.flush-interval-ms:1000}")
|
||||||
|
public void flushAll() {
|
||||||
|
flushExecutions();
|
||||||
|
flushDiagrams();
|
||||||
|
flushMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flushExecutions() {
|
||||||
|
try {
|
||||||
|
List<RouteExecution> batch = executionBuffer.drain(batchSize);
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
executionRepository.insertBatch(batch);
|
||||||
|
log.debug("Flushed {} executions to ClickHouse", batch.size());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to flush executions to ClickHouse", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flushDiagrams() {
|
||||||
|
try {
|
||||||
|
List<RouteGraph> batch = diagramBuffer.drain(batchSize);
|
||||||
|
for (RouteGraph graph : batch) {
|
||||||
|
diagramRepository.store(graph);
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
log.debug("Flushed {} diagrams to ClickHouse", batch.size());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to flush diagrams to ClickHouse", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flushMetrics() {
|
||||||
|
try {
|
||||||
|
List<MetricsSnapshot> batch = metricsBuffer.drain(batchSize);
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
metricsRepository.insertBatch(batch);
|
||||||
|
log.debug("Flushed {} metrics to ClickHouse", batch.size());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to flush metrics to ClickHouse", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmartLifecycle -- flush remaining data on shutdown
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
running = true;
|
||||||
|
log.info("ClickHouseFlushScheduler started");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
log.info("ClickHouseFlushScheduler stopping -- flushing remaining data");
|
||||||
|
drainAll();
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getPhase() {
|
||||||
|
// Run after most beans but before DataSource shutdown
|
||||||
|
return Integer.MAX_VALUE - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain all buffers completely (loop until empty).
|
||||||
|
*/
|
||||||
|
private void drainAll() {
|
||||||
|
drainBufferCompletely("executions", executionBuffer, batch -> executionRepository.insertBatch(batch));
|
||||||
|
drainBufferCompletely("diagrams", diagramBuffer, batch -> {
|
||||||
|
for (RouteGraph g : batch) {
|
||||||
|
diagramRepository.store(g);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
drainBufferCompletely("metrics", metricsBuffer, batch -> metricsRepository.insertBatch(batch));
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> void drainBufferCompletely(String name, WriteBuffer<T> buffer, java.util.function.Consumer<List<T>> inserter) {
|
||||||
|
int total = 0;
|
||||||
|
while (buffer.size() > 0) {
|
||||||
|
List<T> batch = buffer.drain(batchSize);
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
inserter.accept(batch);
|
||||||
|
total += batch.size();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to flush remaining {} during shutdown", name, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total > 0) {
|
||||||
|
log.info("Flushed {} remaining {} during shutdown", total, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.common.graph.RouteGraph;
|
||||||
|
import com.cameleer3.server.core.storage.DiagramRepository;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClickHouse implementation of {@link DiagramRepository}.
|
||||||
|
* <p>
|
||||||
|
* Stores route graphs as JSON with SHA-256 content-hash deduplication.
|
||||||
|
* The underlying table uses ReplacingMergeTree keyed on content_hash.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class ClickHouseDiagramRepository implements DiagramRepository {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClickHouseDiagramRepository.class);
|
||||||
|
|
||||||
|
private static final String INSERT_SQL = """
|
||||||
|
INSERT INTO route_diagrams (content_hash, route_id, agent_id, definition)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String SELECT_BY_HASH = """
|
||||||
|
SELECT definition FROM route_diagrams WHERE content_hash = ? LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String SELECT_HASH_FOR_ROUTE = """
|
||||||
|
SELECT content_hash FROM route_diagrams
|
||||||
|
WHERE route_id = ? AND agent_id = ?
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ClickHouseDiagramRepository(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
this.objectMapper.registerModule(new JavaTimeModule());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void store(RouteGraph graph) {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(graph);
|
||||||
|
String contentHash = sha256Hex(json);
|
||||||
|
String routeId = graph.getRouteId() != null ? graph.getRouteId() : "";
|
||||||
|
// agent_id is not part of RouteGraph -- set empty, controllers can enrich
|
||||||
|
String agentId = "";
|
||||||
|
|
||||||
|
jdbcTemplate.update(INSERT_SQL, contentHash, routeId, agentId, json);
|
||||||
|
log.debug("Stored diagram for route={} with hash={}", routeId, contentHash);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new RuntimeException("Failed to serialize RouteGraph to JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<RouteGraph> findByContentHash(String contentHash) {
|
||||||
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(SELECT_BY_HASH, contentHash);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String json = (String) rows.get(0).get("definition");
|
||||||
|
try {
|
||||||
|
return Optional.of(objectMapper.readValue(json, RouteGraph.class));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Failed to deserialize RouteGraph from ClickHouse", e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> findContentHashForRoute(String routeId, String agentId) {
|
||||||
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(SELECT_HASH_FOR_ROUTE, routeId, agentId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of((String) rows.get(0).get("content_hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String sha256Hex(String input) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(hash);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.common.model.ProcessorExecution;
|
||||||
|
import com.cameleer3.common.model.RouteExecution;
|
||||||
|
import com.cameleer3.server.core.storage.ExecutionRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClickHouse implementation of {@link ExecutionRepository}.
|
||||||
|
* <p>
|
||||||
|
* Performs batch inserts into the {@code route_executions} table.
|
||||||
|
* Processor executions are flattened into parallel arrays.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class ClickHouseExecutionRepository implements ExecutionRepository {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClickHouseExecutionRepository.class);
|
||||||
|
|
||||||
|
private static final String INSERT_SQL = """
|
||||||
|
INSERT INTO route_executions (
|
||||||
|
execution_id, route_id, agent_id, status, start_time, end_time,
|
||||||
|
duration_ms, correlation_id, exchange_id, error_message, error_stacktrace,
|
||||||
|
processor_ids, processor_types, processor_starts, processor_ends,
|
||||||
|
processor_durations, processor_statuses
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public ClickHouseExecutionRepository(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insertBatch(List<RouteExecution> executions) {
|
||||||
|
if (executions.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.batchUpdate(INSERT_SQL, new BatchPreparedStatementSetter() {
|
||||||
|
@Override
|
||||||
|
public void setValues(PreparedStatement ps, int i) throws SQLException {
|
||||||
|
RouteExecution exec = executions.get(i);
|
||||||
|
List<ProcessorExecution> processors = flattenProcessors(exec.getProcessors());
|
||||||
|
|
||||||
|
ps.setString(1, UUID.randomUUID().toString());
|
||||||
|
ps.setString(2, nullSafe(exec.getRouteId()));
|
||||||
|
ps.setString(3, ""); // agent_id set by controller header or empty
|
||||||
|
ps.setString(4, exec.getStatus() != null ? exec.getStatus().name() : "RUNNING");
|
||||||
|
ps.setObject(5, toTimestamp(exec.getStartTime()));
|
||||||
|
ps.setObject(6, toTimestamp(exec.getEndTime()));
|
||||||
|
ps.setLong(7, exec.getDurationMs());
|
||||||
|
ps.setString(8, nullSafe(exec.getCorrelationId()));
|
||||||
|
ps.setString(9, nullSafe(exec.getExchangeId()));
|
||||||
|
ps.setString(10, nullSafe(exec.getErrorMessage()));
|
||||||
|
ps.setString(11, nullSafe(exec.getErrorStackTrace()));
|
||||||
|
|
||||||
|
// Parallel arrays for processor executions
|
||||||
|
ps.setObject(12, processors.stream().map(p -> nullSafe(p.getProcessorId())).toArray(String[]::new));
|
||||||
|
ps.setObject(13, processors.stream().map(p -> nullSafe(p.getProcessorType())).toArray(String[]::new));
|
||||||
|
ps.setObject(14, processors.stream().map(p -> toTimestamp(p.getStartTime())).toArray(Timestamp[]::new));
|
||||||
|
ps.setObject(15, processors.stream().map(p -> toTimestamp(p.getEndTime())).toArray(Timestamp[]::new));
|
||||||
|
ps.setObject(16, processors.stream().mapToLong(ProcessorExecution::getDurationMs).boxed().toArray(Long[]::new));
|
||||||
|
ps.setObject(17, processors.stream().map(p -> p.getStatus() != null ? p.getStatus().name() : "RUNNING").toArray(String[]::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getBatchSize() {
|
||||||
|
return executions.size();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("Inserted batch of {} route executions into ClickHouse", executions.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the processor tree into a flat list (depth-first).
|
||||||
|
*/
|
||||||
|
private List<ProcessorExecution> flattenProcessors(List<ProcessorExecution> processors) {
|
||||||
|
if (processors == null || processors.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
var result = new java.util.ArrayList<ProcessorExecution>();
|
||||||
|
for (ProcessorExecution p : processors) {
|
||||||
|
flatten(p, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flatten(ProcessorExecution processor, List<ProcessorExecution> result) {
|
||||||
|
result.add(processor);
|
||||||
|
if (processor.getChildren() != null) {
|
||||||
|
for (ProcessorExecution child : processor.getChildren()) {
|
||||||
|
flatten(child, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullSafe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Timestamp toTimestamp(Instant instant) {
|
||||||
|
return instant != null ? Timestamp.from(instant) : Timestamp.from(Instant.EPOCH);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.storage.MetricsRepository;
|
||||||
|
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClickHouse implementation of {@link MetricsRepository}.
|
||||||
|
* <p>
|
||||||
|
* Performs batch inserts into the {@code agent_metrics} table.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class ClickHouseMetricsRepository implements MetricsRepository {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClickHouseMetricsRepository.class);
|
||||||
|
|
||||||
|
private static final String INSERT_SQL = """
|
||||||
|
INSERT INTO agent_metrics (agent_id, collected_at, metric_name, metric_value, tags)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public ClickHouseMetricsRepository(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insertBatch(List<MetricsSnapshot> metrics) {
|
||||||
|
if (metrics.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.batchUpdate(INSERT_SQL, new BatchPreparedStatementSetter() {
|
||||||
|
@Override
|
||||||
|
public void setValues(PreparedStatement ps, int i) throws SQLException {
|
||||||
|
MetricsSnapshot m = metrics.get(i);
|
||||||
|
ps.setString(1, m.agentId() != null ? m.agentId() : "");
|
||||||
|
ps.setObject(2, m.collectedAt() != null ? Timestamp.from(m.collectedAt()) : Timestamp.from(Instant.EPOCH));
|
||||||
|
ps.setString(3, m.metricName() != null ? m.metricName() : "");
|
||||||
|
ps.setDouble(4, m.metricValue());
|
||||||
|
// ClickHouse Map(String, String) -- pass as a java.util.Map
|
||||||
|
Map<String, String> tags = m.tags() != null ? m.tags() : new HashMap<>();
|
||||||
|
ps.setObject(5, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getBatchSize() {
|
||||||
|
return metrics.size();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("Inserted batch of {} metrics into ClickHouse", metrics.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.cameleer3.server.core.ingestion;
|
||||||
|
|
||||||
|
import com.cameleer3.common.graph.RouteGraph;
|
||||||
|
import com.cameleer3.common.model.RouteExecution;
|
||||||
|
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes incoming data to the appropriate {@link WriteBuffer} instances.
|
||||||
|
* <p>
|
||||||
|
* This is a plain class (no Spring annotations) -- it lives in the core module
|
||||||
|
* and is wired as a bean by the app module configuration.
|
||||||
|
*/
|
||||||
|
public class IngestionService {
|
||||||
|
|
||||||
|
private final WriteBuffer<RouteExecution> executionBuffer;
|
||||||
|
private final WriteBuffer<RouteGraph> diagramBuffer;
|
||||||
|
private final WriteBuffer<MetricsSnapshot> metricsBuffer;
|
||||||
|
|
||||||
|
public IngestionService(WriteBuffer<RouteExecution> executionBuffer,
|
||||||
|
WriteBuffer<RouteGraph> diagramBuffer,
|
||||||
|
WriteBuffer<MetricsSnapshot> metricsBuffer) {
|
||||||
|
this.executionBuffer = executionBuffer;
|
||||||
|
this.diagramBuffer = diagramBuffer;
|
||||||
|
this.metricsBuffer = metricsBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a batch of route executions into the buffer.
|
||||||
|
*
|
||||||
|
* @return true if all items were buffered, false if buffer is full (backpressure)
|
||||||
|
*/
|
||||||
|
public boolean acceptExecutions(List<RouteExecution> executions) {
|
||||||
|
return executionBuffer.offerBatch(executions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a single route execution into the buffer.
|
||||||
|
*
|
||||||
|
* @return true if the item was buffered, false if buffer is full (backpressure)
|
||||||
|
*/
|
||||||
|
public boolean acceptExecution(RouteExecution execution) {
|
||||||
|
return executionBuffer.offer(execution);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a single route diagram into the buffer.
|
||||||
|
*
|
||||||
|
* @return true if the item was buffered, false if buffer is full (backpressure)
|
||||||
|
*/
|
||||||
|
public boolean acceptDiagram(RouteGraph graph) {
|
||||||
|
return diagramBuffer.offer(graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a batch of route diagrams into the buffer.
|
||||||
|
*
|
||||||
|
* @return true if all items were buffered, false if buffer is full (backpressure)
|
||||||
|
*/
|
||||||
|
public boolean acceptDiagrams(List<RouteGraph> graphs) {
|
||||||
|
return diagramBuffer.offerBatch(graphs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a batch of metrics snapshots into the buffer.
|
||||||
|
*
|
||||||
|
* @return true if all items were buffered, false if buffer is full (backpressure)
|
||||||
|
*/
|
||||||
|
public boolean acceptMetrics(List<MetricsSnapshot> metrics) {
|
||||||
|
return metricsBuffer.offerBatch(metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return current number of items in the execution buffer
|
||||||
|
*/
|
||||||
|
public int getExecutionBufferDepth() {
|
||||||
|
return executionBuffer.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return current number of items in the diagram buffer
|
||||||
|
*/
|
||||||
|
public int getDiagramBufferDepth() {
|
||||||
|
return diagramBuffer.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return current number of items in the metrics buffer
|
||||||
|
*/
|
||||||
|
public int getMetricsBufferDepth() {
|
||||||
|
return metricsBuffer.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the execution write buffer (for use by flush scheduler)
|
||||||
|
*/
|
||||||
|
public WriteBuffer<RouteExecution> getExecutionBuffer() {
|
||||||
|
return executionBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the diagram write buffer (for use by flush scheduler)
|
||||||
|
*/
|
||||||
|
public WriteBuffer<RouteGraph> getDiagramBuffer() {
|
||||||
|
return diagramBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the metrics write buffer (for use by flush scheduler)
|
||||||
|
*/
|
||||||
|
public WriteBuffer<MetricsSnapshot> getMetricsBuffer() {
|
||||||
|
return metricsBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user