-
Notifications
You must be signed in to change notification settings - Fork 1.8k
[OPIK-8202] [BE] fix: harden ClickHouseAppender against dropped user logs #8126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import lombok.NonNull; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import reactor.util.retry.Retry; | ||
|
|
||
| import java.time.Duration; | ||
| import java.util.ArrayList; | ||
|
|
@@ -27,6 +28,10 @@ | |
| @Slf4j | ||
| class ClickHouseAppender extends AppenderBase<ILoggingEvent> { | ||
|
|
||
| private static final long INSERT_RETRY_ATTEMPTS = 3; | ||
| private static final Duration INSERT_RETRY_MIN_BACKOFF = Duration.ofMillis(100); | ||
| private static final Retry INSERT_RETRY = Retry.backoff(INSERT_RETRY_ATTEMPTS, INSERT_RETRY_MIN_BACKOFF); | ||
|
|
||
| private static ClickHouseAppender instance; | ||
|
|
||
| public static synchronized ClickHouseAppender init(@NonNull UserLogTableFactory userLogTableFactory, int batchSize, | ||
|
|
@@ -58,13 +63,23 @@ private static void setInstance(ClickHouseAppender instance) { | |
|
|
||
| @Override | ||
| public void start() { | ||
| // Background flush thread | ||
| scheduler.get().scheduleAtFixedRate(this::flushLogs, flushIntervalDuration.toMillis(), | ||
| // Background flush thread. Wrapped in safeFlushLogs because scheduleAtFixedRate silently | ||
| // cancels the task forever if it ever throws, which would stop persisting user logs for the | ||
| // rest of the JVM's life. | ||
| scheduler.get().scheduleAtFixedRate(this::safeFlushLogs, flushIntervalDuration.toMillis(), | ||
| flushIntervalDuration.toMillis(), TimeUnit.MILLISECONDS); | ||
|
|
||
| super.start(); | ||
| } | ||
|
|
||
| private void safeFlushLogs() { | ||
| try { | ||
| flushLogs(); | ||
| } catch (Exception e) { | ||
|
JetoPistola marked this conversation as resolved.
|
||
| log.error("Failed to flush logs", e); | ||
| } | ||
| } | ||
|
|
||
| private void flushLogs() { | ||
| if (logQueue.isEmpty()) return; | ||
|
|
||
|
|
@@ -87,16 +102,43 @@ private void flushLogs() { | |
| UserLogTableFactory.UserLogTableDAO tableDAO = userLogTableFactory | ||
| .getDAO(UserLog.valueOf(userLog)); | ||
|
|
||
| tableDAO | ||
| .saveAll(events) | ||
| .subscribe( | ||
| noop -> { | ||
| }, | ||
| e -> log.error("Failed to insert logs", e)); | ||
| // The batch is already drained out of the queue, so a failed insert would lose | ||
| // these events outright. Retry transient failures, and put the events back on | ||
| // the queue if the retries are exhausted so a later flush can pick them up. | ||
| try { | ||
| tableDAO | ||
| .saveAll(events) | ||
| .retryWhen(INSERT_RETRY) | ||
| .subscribe( | ||
| noop -> { | ||
| }, | ||
| e -> { | ||
| log.error("Failed to insert logs", e); | ||
| requeue(events); | ||
|
JetoPistola marked this conversation as resolved.
|
||
| }); | ||
|
JetoPistola marked this conversation as resolved.
|
||
| } catch (Exception e) { | ||
| // A synchronous throw from saveAll means the batch itself is rejected before | ||
| // any I/O (e.g. an event missing workspace_id or rule_id). Requeueing would | ||
| // fail identically on every later flush, so drop it instead of looping. | ||
| log.error("Dropping '{}' logs rejected before insert", events.size(), e); | ||
|
Comment on lines
+120
to
+123
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expected log drops pollute ERROR telemetryHandled validation failures such as missing Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents |
||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private void requeue(List<ILoggingEvent> events) { | ||
| if (!running) { | ||
| log.warn("ClickHouseAppender is stopped, dropping '{}' logs after failed insert", events.size()); | ||
| return; | ||
| } | ||
|
|
||
| for (ILoggingEvent event : events) { | ||
| if (!logQueue.offer(event)) { | ||
| log.warn("Log queue is full, dropping log: {}", event.getFormattedMessage()); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| protected void append(ILoggingEvent event) { | ||
| if (!running) { | ||
|
|
@@ -110,7 +152,7 @@ protected void append(ILoggingEvent event) { | |
| } | ||
|
|
||
| if (logQueue.size() >= batchSize) { | ||
| scheduler.get().execute(this::flushLogs); | ||
| scheduler.get().execute(this::safeFlushLogs); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| package com.comet.opik.infrastructure.log; | ||
|
|
||
| import ch.qos.logback.classic.Level; | ||
| import ch.qos.logback.classic.LoggerContext; | ||
| import ch.qos.logback.classic.spi.ILoggingEvent; | ||
| import ch.qos.logback.classic.spi.LoggingEvent; | ||
| import com.comet.opik.domain.evaluators.UserLog; | ||
| import com.comet.opik.infrastructure.log.tables.UserLogTableFactory; | ||
| import org.junit.jupiter.api.AfterEach; | ||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
| import reactor.core.publisher.Mono; | ||
|
|
||
| import java.time.Duration; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.concurrent.ConcurrentLinkedQueue; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicInteger; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.awaitility.Awaitility.await; | ||
|
|
||
| @DisplayName("ClickHouse Appender") | ||
| class ClickHouseAppenderTest { | ||
|
|
||
| private static final Duration FLUSH_INTERVAL = Duration.ofMillis(50); | ||
| private static final int BATCH_SIZE = 1000; | ||
| private static final int AWAIT_TIMEOUT_SECONDS = 10; | ||
|
|
||
| private ClickHouseAppender appender; | ||
|
|
||
| @AfterEach | ||
| void tearDown() { | ||
| if (appender != null) { | ||
| appender.stop(); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("when the insert fails transiently, then the events are retried and still persisted") | ||
| void whenInsertFailsTransiently__thenEventsAreRetriedAndPersisted() { | ||
| var attempts = new AtomicInteger(); | ||
| var persisted = new ConcurrentLinkedQueue<ILoggingEvent>(); | ||
|
|
||
| // Fail the first attempt, then accept. Without the retry, the drained batch would be lost. | ||
| startAppender(events -> Mono.defer(() -> { | ||
| if (attempts.incrementAndGet() == 1) { | ||
| return Mono.error(new IllegalStateException("transient failure")); | ||
| } | ||
| persisted.addAll(events); | ||
| return Mono.empty(); | ||
| })); | ||
|
|
||
| appender.doAppend(event("a message")); | ||
|
|
||
| // The attempt count is what pins Retry.backoff: without it a batch requeued and picked up by a | ||
| // later flush would also persist eventually, so hasSize(1) alone would not prove a retry ran. | ||
| await().atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) | ||
| .untilAsserted(() -> { | ||
| assertThat(persisted).hasSize(1); | ||
| assertThat(attempts).hasValue(2); | ||
| }); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("when the batch is rejected before insert, then it is dropped rather than requeued") | ||
| void whenBatchRejectedBeforeInsert__thenItIsDroppedRatherThanRequeued() { | ||
| var attempts = new AtomicInteger(); | ||
|
|
||
| // A synchronous throw means the batch is malformed (e.g. missing workspace_id), so it would | ||
| // fail identically forever. Requeueing it would spin the flush thread on a poison batch. | ||
| startAppender(events -> { | ||
| attempts.incrementAndGet(); | ||
| throw new IllegalStateException("workspace_id is not set"); | ||
| }); | ||
|
|
||
|
Comment on lines
+66
to
+77
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exhausted retries can silently lose logsThe new coverage never exhausts Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents |
||
| appender.doAppend(event("a malformed message")); | ||
|
|
||
| await().atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) | ||
| .untilAsserted(() -> assertThat(attempts).hasValue(1)); | ||
|
|
||
| // Give several further flush intervals a chance to re-attempt the dropped batch. | ||
| await().during(FLUSH_INTERVAL.multipliedBy(10)) | ||
| .atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) | ||
| .untilAsserted(() -> assertThat(attempts).hasValue(1)); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("when a flush throws, then subsequent flushes still run") | ||
| void whenFlushThrows__thenSubsequentFlushesStillRun() { | ||
| var persisted = new ConcurrentLinkedQueue<ILoggingEvent>(); | ||
| var failNext = new AtomicInteger(1); | ||
|
|
||
| // A throw escaping into scheduleAtFixedRate would cancel the periodic flush permanently, | ||
| // silently stopping log persistence for the rest of the JVM's life. | ||
| startAppender(events -> { | ||
| if (failNext.getAndSet(0) == 1) { | ||
| throw new IllegalStateException("failure inside flush"); | ||
| } | ||
| persisted.addAll(events); | ||
| return Mono.empty(); | ||
| }); | ||
|
|
||
| appender.doAppend(event("first")); | ||
|
|
||
| // The first batch is dropped (it was rejected before insert), so the surviving scheduler is | ||
| // proven by a later event still being persisted rather than by the failed batch reappearing. | ||
| await().atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) | ||
| .untilAsserted(() -> assertThat(failNext).hasValue(0)); | ||
|
|
||
| appender.doAppend(event("second")); | ||
|
|
||
| await().atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) | ||
| .untilAsserted(() -> assertThat(persisted).hasSize(1)); | ||
| } | ||
|
|
||
| private void startAppender(SaveAll saveAll) { | ||
| UserLogTableFactory tableFactory = userLog -> saveAll::apply; | ||
|
|
||
| appender = ClickHouseAppender.init(tableFactory, BATCH_SIZE, FLUSH_INTERVAL, new LoggerContext()); | ||
| } | ||
|
|
||
| private ILoggingEvent event(String message) { | ||
| var event = new LoggingEvent(); | ||
| event.setLevel(Level.INFO); | ||
| event.setMessage(message); | ||
| event.setMDCPropertyMap(Map.of(UserLog.MARKER, UserLog.AUTOMATION_RULE_EVALUATOR.name())); | ||
| return event; | ||
| } | ||
|
|
||
| @FunctionalInterface | ||
| private interface SaveAll { | ||
| Mono<Void> apply(List<ILoggingEvent> events); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardcoded retry policy blocks operational tuning
ClickHouseAppenderhardcodesRetry.backoff(INSERT_RETRY_ATTEMPTS, INSERT_RETRY_MIN_BACKOFF), so operators cannot tune or disable ClickHouse insert retries at runtime — should we move these values into a validated typed configuration block with YAML defaults and environment overrides, then build the policy from that shared configuration?Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents