feat(events): cursor-paginate agent events (ClickHouse impl)

Orders by (timestamp DESC, instance_id ASC). Cursor is
base64url('timestampIso|instanceId') with a tuple keyset predicate
for stable paging across ties.
This commit is contained in:
hsiegeln
2026-04-17 11:57:35 +02:00
parent 67a834153e
commit d293dafb99
2 changed files with 139 additions and 20 deletions

View File

@@ -1,12 +1,15 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.agent.AgentEventPage;
import com.cameleer.server.core.agent.AgentEventRecord;
import com.cameleer.server.core.agent.AgentEventRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
@@ -43,26 +46,11 @@ public class ClickHouseAgentEventRepository implements AgentEventRepository {
var params = new ArrayList<Object>();
params.add(tenantId);
if (applicationId != null) {
sql.append(" AND application_id = ?");
params.add(applicationId);
}
if (instanceId != null) {
sql.append(" AND instance_id = ?");
params.add(instanceId);
}
if (environment != null) {
sql.append(" AND environment = ?");
params.add(environment);
}
if (from != null) {
sql.append(" AND timestamp >= ?");
params.add(Timestamp.from(from));
}
if (to != null) {
sql.append(" AND timestamp < ?");
params.add(Timestamp.from(to));
}
if (applicationId != null) { sql.append(" AND application_id = ?"); params.add(applicationId); }
if (instanceId != null) { sql.append(" AND instance_id = ?"); params.add(instanceId); }
if (environment != null) { sql.append(" AND environment = ?"); params.add(environment); }
if (from != null) { sql.append(" AND timestamp >= ?"); params.add(Timestamp.from(from)); }
if (to != null) { sql.append(" AND timestamp < ?"); params.add(Timestamp.from(to)); }
sql.append(" ORDER BY timestamp DESC LIMIT ?");
params.add(limit);
@@ -75,4 +63,61 @@ public class ClickHouseAgentEventRepository implements AgentEventRepository {
rs.getTimestamp("timestamp").toInstant()
), params.toArray());
}
@Override
public AgentEventPage queryPage(String applicationId, String instanceId, String environment,
Instant from, Instant to, String cursor, int limit) {
var sql = new StringBuilder(SELECT_BASE);
var params = new ArrayList<Object>();
params.add(tenantId);
if (applicationId != null) { sql.append(" AND application_id = ?"); params.add(applicationId); }
if (instanceId != null) { sql.append(" AND instance_id = ?"); params.add(instanceId); }
if (environment != null) { sql.append(" AND environment = ?"); params.add(environment); }
if (from != null) { sql.append(" AND timestamp >= ?"); params.add(Timestamp.from(from)); }
if (to != null) { sql.append(" AND timestamp < ?"); params.add(Timestamp.from(to)); }
if (cursor != null && !cursor.isEmpty()) {
String decoded = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int bar = decoded.indexOf('|');
if (bar <= 0) {
throw new IllegalArgumentException("Malformed cursor");
}
Instant cursorTs = Instant.parse(decoded.substring(0, bar));
String cursorInstance = decoded.substring(bar + 1);
sql.append(" AND (timestamp < ? OR (timestamp = ? AND instance_id > ?))");
params.add(Timestamp.from(cursorTs));
params.add(Timestamp.from(cursorTs));
params.add(cursorInstance);
}
sql.append(" ORDER BY timestamp DESC, instance_id ASC LIMIT ?");
int fetchLimit = limit + 1;
params.add(fetchLimit);
List<AgentEventRecord> results = new ArrayList<>(jdbc.query(sql.toString(),
(rs, rowNum) -> new AgentEventRecord(
rs.getLong("id"),
rs.getString("instance_id"),
rs.getString("application_id"),
rs.getString("event_type"),
rs.getString("detail"),
rs.getTimestamp("timestamp").toInstant()
), params.toArray()));
boolean hasMore = results.size() > limit;
if (hasMore) {
results = new ArrayList<>(results.subList(0, limit));
}
String nextCursor = null;
if (hasMore && !results.isEmpty()) {
AgentEventRecord last = results.get(results.size() - 1);
String raw = last.timestamp().toString() + "|" + last.instanceId();
nextCursor = Base64.getUrlEncoder().withoutPadding()
.encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
return new AgentEventPage(results, nextCursor, hasMore);
}
}