Skip to content

Commit 472017a

Browse files
thiagohoraclaude
andcommitted
[OPIK-8262] [BE] fix: classify provider errors by status and split thread fan-out
Two coupled fixes to online scoring. They ship together because the second only becomes a correctness problem once the first lands. Retry classification. scoreTrace answered every provider failure with a blanket InternalServerErrorException, so a request the provider can never accept -- an oversized body rejected with a plain-text 400 -- was replayed maxRetries times, one delivery per pendingMessageDuration, before being retired. It now classifies by status code rather than by 4xx/5xx family: 400/401/403 and friends become ClientErrorException, which BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS retires on the first delivery, while 408/425/429 and every 5xx stay a retryable 500. Family alone is too blunt -- 408/425/429 are "not now", not "not ever". Permanence is decided only from a status the provider actually put on the wire. The provider mappers are not consulted, not even as a fallback: they synthesize a code when they cannot parse the body (CustomLlm 400, OpenAi 500), and nothing downstream can tell a parsed 400 from that default, so trusting them would drop every unparseable CustomLlm failure on its first delivery. Needlessly retrying a permanent error costs maxRetries attempts; dropping an unknown failure loses it forever, so an absent wire status stays retryable. Thread fan-out. The two trace-thread scorers took a message carrying a list of thread ids, fanned out, and collapsed all per-thread errors into one arbitrary re-emitted error. That was harmless only while every provider failure was retryable; after the split a fan-out can mix a permanent 400 with a transient 429, and an arbitrary pick either drops retryable work or replays threads that already succeeded. Rather than choose a better victim, remove the collapse: the subscriber acks and removes per stream entry, so OnlineScorePublisher now writes one entry per thread id and failure granularity matches ack granularity. A retry re-runs only the thread that failed, so no sibling is ever replayed. Entries left by the previous build carry several ids. Those are migrated, not scored: the consumer republishes them as N single-id entries and completes, which is what acks the original. A failed republish does not ack, so the entry redelivers and the split retries; a successful republish whose ack fails splits twice, and the duplicate scores are absorbed by feedback_scores being a ReplacingMergeTree. The branch is a shim, deletable once no pre-deploy entry can be in flight. The pipeline lives in OnlineScoringBaseScorer so it cannot drift between the two scorers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a3d1e2a commit 472017a

11 files changed

Lines changed: 725 additions & 94 deletions

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringBaseScorer.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.comet.opik.api.FeedbackScoreItem;
44
import com.comet.opik.api.Span;
55
import com.comet.opik.api.Trace;
6+
import com.comet.opik.api.Visibility;
67
import com.comet.opik.api.evaluators.AutomationRuleEvaluatorType;
78
import com.comet.opik.api.events.RedisSubscriberMessage;
89
import com.comet.opik.api.filter.Operator;
@@ -12,6 +13,7 @@
1213
import com.comet.opik.domain.SpanService;
1314
import com.comet.opik.domain.TraceSearchCriteria;
1415
import com.comet.opik.domain.TraceService;
16+
import com.comet.opik.domain.evaluators.OnlineScorePublisher;
1517
import com.comet.opik.infrastructure.OnlineScoringConfig;
1618
import com.comet.opik.infrastructure.OnlineScoringStreamConfigurationAdapter;
1719
import com.comet.opik.infrastructure.auth.RequestContext;
@@ -31,6 +33,7 @@
3133
import java.util.Set;
3234
import java.util.UUID;
3335
import java.util.concurrent.atomic.AtomicReference;
36+
import java.util.function.Function;
3437
import java.util.stream.Collectors;
3538

3639
import static com.comet.opik.api.FeedbackScoreItem.FeedbackScoreBatchItem;
@@ -229,6 +232,76 @@ private static <T extends FeedbackScoreItem> Map<String, List<BigDecimal>> group
229232
* @return a Flux of Trace objects representing the full thread context
230233
*/
231234
//TODO: Move this to a common service or utility class
235+
/**
236+
* The whole per-entry pipeline for a trace-thread scorer: migrate a legacy multi-id entry, or score
237+
* the single thread id this build's entries carry. Shared by both trace-thread scorers so the
238+
* migration path cannot drift between them — message construction and the scorer's own logging stay
239+
* with the caller, only the branching lives here.
240+
*
241+
* <p><b>One thread id per entry.</b> Since OPIK-8262 the publisher writes one entry per thread id, so
242+
* failure granularity matches {@link BaseRedisSubscriber}'s per-entry ack/remove granularity: a retry
243+
* re-runs only the thread that failed, and no sibling is ever replayed. The single-id branch therefore
244+
* just returns the scoring Mono — its error reaches
245+
* {@code BaseRedisSubscriber.processMessage}'s {@code onErrorResume} directly and is classified
246+
* retryable or not for this entry alone. There is deliberately no {@code Flux.flatMap} here, so
247+
* nothing can leak into the enclosing {@code onErrorContinue} and be miscounted as an "unexpected"
248+
* error.
249+
*
250+
* <p><b>Multi-id entries are migrated, not scored (rolling-upgrade shim).</b> Entries written by the
251+
* previous build carry several ids. Scoring those in place would need a rule for reducing N per-thread
252+
* outcomes into the one verdict the entry gets — a second set of semantics, and one that must
253+
* mis-serve somebody whenever a permanent failure and a retryable one land under the same entry.
254+
* Converting the entry to the new format instead means there is no verdict to pick between siblings,
255+
* no amplification, and nothing lost: each thread id becomes its own entry with its own retry budget,
256+
* scored by the ordinary single-id path.
257+
*
258+
* <p><b>Ack is implicit, and that is what makes the failure modes right.</b> The republish itself is
259+
* returned, so the base subscriber's normal success/failure handling supplies the ack:
260+
* <ul>
261+
* <li><b>Republish fails</b> — the returned Mono errors, the entry is <em>not</em> acked, and it
262+
* redelivers so the split is retried. Nothing is lost.</li>
263+
* <li><b>Republish succeeds, ack/remove fails</b> — the entry redelivers and splits again, so some
264+
* thread ids get duplicate single-id entries and are scored twice. Tolerable rather than merely
265+
* unlikely: {@code feedback_scores} is a {@code ReplicatedReplacingMergeTree} versioned on
266+
* {@code last_updated_at} (migration {@code 000017}), so the second score overwrites the first
267+
* rather than accumulating a duplicate row. The cost is wasted provider calls, not wrong data.</li>
268+
* </ul>
269+
*
270+
* <p><b>The multi-id branch is temporary.</b> It can only fire for entries written before the deploy
271+
* that ships OPIK-8262. Once no such entry can be in flight — one full {@code streamMaxLen} turnover
272+
* past the rollout, at the latest — this branch, and the {@code List<String>} shape of
273+
* {@code threadIds}, are both deletable.
274+
*
275+
* @param message the entry being processed, read for its workspace and user
276+
* @param threadIds the entry's thread ids; exactly one for anything this build wrote
277+
* @param publisher writes the replacement entries onto this scorer's own stream
278+
* @param singleThreadIdCopy builds a copy of {@code message} carrying only the given thread id; the
279+
* rule code, workspace and user are preserved verbatim, so no rule lookup
280+
* or evaluator-toggle re-check is involved in the migration
281+
* @param scoreThread scores one thread id, on the ordinary path
282+
* @return a {@link Mono} completing when the entry has been scored or migrated
283+
*/
284+
protected Mono<Void> scoreOneThreadIdPerEntry(@NonNull M message, @NonNull List<String> threadIds,
285+
@NonNull OnlineScorePublisher publisher, @NonNull Function<String, M> singleThreadIdCopy,
286+
@NonNull Function<String, Mono<Void>> scoreThread) {
287+
if (threadIds.size() > 1) {
288+
log.info("Splitting legacy entry of '{}' thread ids into one entry each for workspace '{}'",
289+
threadIds.size(), message.workspaceId());
290+
return publisher.enqueueMessage(threadIds.stream().map(singleThreadIdCopy).toList(), type);
291+
}
292+
// @NotEmpty on the message record says this cannot happen. If it somehow does there is nothing to
293+
// score and no retry could change that, so complete normally and let the subscriber ack it away
294+
// rather than cycle it through maxRetries.
295+
if (threadIds.isEmpty()) {
296+
log.warn("Discarding trace-thread entry with no thread ids for workspace '{}'", message.workspaceId());
297+
return Mono.empty();
298+
}
299+
return scoreThread.apply(threadIds.getFirst())
300+
.contextWrite(context -> context.put(RequestContext.WORKSPACE_ID, message.workspaceId())
301+
.put(RequestContext.USER_NAME, message.userName())
302+
.put(RequestContext.VISIBILITY, Visibility.PRIVATE));
303+
}
304+
232305
protected Flux<Trace> retrieveFullThreadContext(@NotNull String threadId,
233306
@NotNull AtomicReference<UUID> lastReceivedIdRef, @NotNull UUID projectId) {
234307

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringTraceThreadLlmAsJudgeScorer.java

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import com.comet.opik.api.ScoreSource;
55
import com.comet.opik.api.Span;
66
import com.comet.opik.api.Trace;
7-
import com.comet.opik.api.Visibility;
87
import com.comet.opik.api.attachment.EntityType;
98
import com.comet.opik.api.evaluators.AutomationRuleEvaluator;
109
import com.comet.opik.api.evaluators.LlmAsJudgeMessage;
@@ -19,6 +18,7 @@
1918
import com.comet.opik.domain.evaluation.EvaluationRecorder;
2019
import com.comet.opik.domain.evaluation.OnlineEvaluationRecorder;
2120
import com.comet.opik.domain.evaluators.AutomationRuleEvaluatorService;
21+
import com.comet.opik.domain.evaluators.OnlineScorePublisher;
2222
import com.comet.opik.domain.evaluators.UserLog;
2323
import com.comet.opik.domain.llm.ChatCompletionService;
2424
import com.comet.opik.domain.llm.LlmProviderFactory;
@@ -35,7 +35,6 @@
3535
import lombok.extern.slf4j.Slf4j;
3636
import org.redisson.api.RedissonReactiveClient;
3737
import org.slf4j.Logger;
38-
import reactor.core.publisher.Flux;
3938
import reactor.core.publisher.Mono;
4039
import reactor.core.scheduler.Schedulers;
4140
import ru.vyarus.dropwizard.guice.module.installer.feature.eager.EagerSingleton;
@@ -70,6 +69,7 @@ public class OnlineScoringTraceThreadLlmAsJudgeScorer extends OnlineScoringBaseS
7069
private final ServiceTogglesConfig serviceTogglesConfig;
7170
private final OnlineEvaluationRecorder onlineEvaluationRecorder;
7271
private final AttachmentService attachmentService;
72+
private final OnlineScorePublisher onlineScorePublisher;
7373

7474
@Inject
7575
public OnlineScoringTraceThreadLlmAsJudgeScorer(@NonNull @Config("onlineScoring") OnlineScoringConfig config,
@@ -85,7 +85,8 @@ public OnlineScoringTraceThreadLlmAsJudgeScorer(@NonNull @Config("onlineScoring"
8585
@NonNull AgenticScoringService agenticScoringService,
8686
@NonNull SpanService spanService,
8787
@NonNull OnlineEvaluationRecorder onlineEvaluationRecorder,
88-
@NonNull AttachmentService attachmentService) {
88+
@NonNull AttachmentService attachmentService,
89+
@NonNull OnlineScorePublisher onlineScorePublisher) {
8990
super(config, redisson, feedbackScoreService, traceService, spanService, TRACE_THREAD_LLM_AS_JUDGE,
9091
Constants.TRACE_THREAD_LLM_AS_JUDGE);
9192
this.aiProxyService = aiProxyService;
@@ -97,6 +98,7 @@ public OnlineScoringTraceThreadLlmAsJudgeScorer(@NonNull @Config("onlineScoring"
9798
this.serviceTogglesConfig = serviceTogglesConfig;
9899
this.onlineEvaluationRecorder = onlineEvaluationRecorder;
99100
this.attachmentService = attachmentService;
101+
this.onlineScorePublisher = onlineScorePublisher;
100102
this.userFacingLogger = UserFacingLoggingFactory.getLogger(OnlineScoringTraceThreadLlmAsJudgeScorer.class);
101103
}
102104

@@ -112,22 +114,12 @@ protected Mono<Void> score(@NonNull TraceThreadToScoreLlmAsJudge message) {
112114
log.info("Message received with projectId: '{}', ruleId: '{}', threadIds: '{}' for workspace '{}'",
113115
message.projectId(), message.ruleId(), message.threadIds(), message.workspaceId());
114116

115-
return Flux.fromIterable(message.threadIds())
116-
// Score each thread id independently: a single thread's failure must not stop scoring the
117-
// sibling thread ids. Per-thread errors are materialized (onErrorResume) so the flatMap
118-
// completes for every thread; the batch's first failure is then re-surfaced below. This keeps
119-
// the failure on the Mono error path handled by BaseRedisSubscriber.processMessage's
120-
// onErrorResume — classified as a processing error, following the normal retryable/
121-
// non-retryable path — instead of leaking into the enclosing onErrorContinue via Flux.flatMap
122-
// (which would drop the element and count it as an "unexpected" error).
123-
.flatMap(threadId -> processThreadScores(message, threadId)
124-
.then(Mono.<Throwable>empty())
125-
.onErrorResume(Mono::just))
126-
.collectList()
127-
.flatMap(errors -> errors.isEmpty() ? Mono.<Void>empty() : Mono.error(errors.getFirst()))
128-
.contextWrite(context -> context.put(RequestContext.WORKSPACE_ID, message.workspaceId())
129-
.put(RequestContext.USER_NAME, message.userName())
130-
.put(RequestContext.VISIBILITY, Visibility.PRIVATE))
117+
// Branching (legacy multi-id migration vs the ordinary single-id path) lives in
118+
// OnlineScoringBaseScorer so it cannot drift between the two trace-thread scorers; message
119+
// construction and the logging below stay here.
120+
return scoreOneThreadIdPerEntry(message, message.threadIds(), onlineScorePublisher,
121+
threadId -> message.toBuilder().threadIds(List.of(threadId)).build(),
122+
threadId -> processThreadScores(message, threadId))
131123
.doOnSuccess(unused -> log.info(
132124
"Processed trace threads for projectId '{}', ruleId '{}' for workspace '{}'",
133125
message.projectId(), message.ruleId(), message.workspaceId()))

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringTraceThreadUserDefinedMetricPythonScorer.java

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
import com.comet.opik.api.ScoreSource;
55
import com.comet.opik.api.Span;
66
import com.comet.opik.api.Trace;
7-
import com.comet.opik.api.Visibility;
87
import com.comet.opik.api.evaluators.AutomationRuleEvaluator;
98
import com.comet.opik.api.events.TraceThreadToScoreUserDefinedMetricPython;
109
import com.comet.opik.domain.FeedbackScoreService;
1110
import com.comet.opik.domain.ProjectService;
1211
import com.comet.opik.domain.SpanService;
1312
import com.comet.opik.domain.TraceService;
1413
import com.comet.opik.domain.evaluators.AutomationRuleEvaluatorService;
14+
import com.comet.opik.domain.evaluators.OnlineScorePublisher;
1515
import com.comet.opik.domain.evaluators.UserLog;
1616
import com.comet.opik.domain.evaluators.python.PythonEvaluatorService;
1717
import com.comet.opik.domain.threads.TraceThreadService;
@@ -26,7 +26,6 @@
2626
import org.apache.commons.lang3.tuple.Pair;
2727
import org.redisson.api.RedissonReactiveClient;
2828
import org.slf4j.Logger;
29-
import reactor.core.publisher.Flux;
3029
import reactor.core.publisher.Mono;
3130
import reactor.core.scheduler.Schedulers;
3231
import ru.vyarus.dropwizard.guice.module.installer.feature.eager.EagerSingleton;
@@ -62,6 +61,7 @@ public class OnlineScoringTraceThreadUserDefinedMetricPythonScorer
6261
private final ProjectService projectService;
6362
private final AutomationRuleEvaluatorService automationRuleEvaluatorService;
6463
private final AgenticScoringService agenticScoringService;
64+
private final OnlineScorePublisher onlineScorePublisher;
6565

6666
@Inject
6767
public OnlineScoringTraceThreadUserDefinedMetricPythonScorer(
@@ -75,7 +75,8 @@ public OnlineScoringTraceThreadUserDefinedMetricPythonScorer(
7575
@NonNull ProjectService projectService,
7676
@NonNull AutomationRuleEvaluatorService automationRuleEvaluatorService,
7777
@NonNull SpanService spanService,
78-
@NonNull AgenticScoringService agenticScoringService) {
78+
@NonNull AgenticScoringService agenticScoringService,
79+
@NonNull OnlineScorePublisher onlineScorePublisher) {
7980
super(config, redisson, feedbackScoreService, traceService, spanService,
8081
TRACE_THREAD_USER_DEFINED_METRIC_PYTHON,
8182
Constants.TRACE_THREAD_USER_DEFINED_METRIC_PYTHON);
@@ -85,6 +86,7 @@ public OnlineScoringTraceThreadUserDefinedMetricPythonScorer(
8586
this.projectService = projectService;
8687
this.automationRuleEvaluatorService = automationRuleEvaluatorService;
8788
this.agenticScoringService = agenticScoringService;
89+
this.onlineScorePublisher = onlineScorePublisher;
8890
this.userFacingLogger = UserFacingLoggingFactory
8991
.getLogger(OnlineScoringTraceThreadUserDefinedMetricPythonScorer.class);
9092
}
@@ -104,22 +106,12 @@ protected Mono<Void> score(@NonNull TraceThreadToScoreUserDefinedMetricPython me
104106
log.info("Message received with projectId '{}', ruleId '{}' for workspace '{}'",
105107
message.projectId(), message.ruleId(), message.workspaceId());
106108

107-
return Flux.fromIterable(message.threadIds())
108-
// Score each thread id independently: a single thread's failure must not stop scoring the
109-
// sibling thread ids. Per-thread errors are materialized (onErrorResume) so the flatMap
110-
// completes for every thread; the batch's first failure is then re-surfaced below. This keeps
111-
// the failure on the Mono error path handled by BaseRedisSubscriber.processMessage's
112-
// onErrorResume — classified as a processing error, following the normal retryable/
113-
// non-retryable path — instead of leaking into the enclosing onErrorContinue via Flux.flatMap
114-
// (which would drop the element and count it as an "unexpected" error).
115-
.flatMap(threadId -> processThreadScores(message, threadId)
116-
.then(Mono.<Throwable>empty())
117-
.onErrorResume(Mono::just))
118-
.collectList()
119-
.flatMap(errors -> errors.isEmpty() ? Mono.<Void>empty() : Mono.error(errors.getFirst()))
120-
.contextWrite(context -> context.put(RequestContext.WORKSPACE_ID, message.workspaceId())
121-
.put(RequestContext.USER_NAME, message.userName())
122-
.put(RequestContext.VISIBILITY, Visibility.PRIVATE))
109+
// Branching (legacy multi-id migration vs the ordinary single-id path) lives in
110+
// OnlineScoringBaseScorer so it cannot drift between the two trace-thread scorers; message
111+
// construction and the logging below stay here.
112+
return scoreOneThreadIdPerEntry(message, message.threadIds(), onlineScorePublisher,
113+
threadId -> message.toBuilder().threadIds(List.of(threadId)).build(),
114+
threadId -> processThreadScores(message, threadId))
123115
.doOnSuccess(unused -> log.info(
124116
"Processed trace threads for projectId '{}', ruleId '{}' for workspace '{}'",
125117
message.projectId(), message.ruleId(), message.workspaceId()))

apps/opik-backend/src/main/java/com/comet/opik/domain/evaluators/ManualEvaluationService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,9 @@ private Mono<Integer> evaluateTraces(List<UUID> traceIds, List<AutomationRuleEva
185185
.toList();
186186
Mono<Void> traceThreadMono = Flux.fromIterable(traceThreadRules)
187187
.flatMap(rule -> {
188-
log.info("Enqueueing trace-thread evaluation for rule '{}' with '{}' trace IDs", rule.getId(),
189-
traceIdStrings.size());
188+
// One stream entry per trace ID (OPIK-8262), not one entry for the batch.
189+
log.info("Enqueueing '{}' trace-thread evaluation messages, one per trace ID, for rule '{}'",
190+
traceIdStrings.size(), rule.getId());
190191
return onlineScorePublisher.enqueueThreadMessage(traceIdStrings, rule, projectId, workspaceId,
191192
userName);
192193
})
@@ -384,8 +385,9 @@ private Mono<Integer> evaluateThreads(List<UUID> threadModelIds, List<Automation
384385
// reactive context. enqueueThreadMessage does a blocking rule lookup, so defer onto boundedElastic.
385386
return Flux.fromIterable(rules)
386387
.flatMap(rule -> {
387-
log.info("Enqueueing evaluation for rule '{}' with '{}' thread IDs", rule.getId(),
388-
threadIds.size());
388+
// One stream entry per thread ID (OPIK-8262), not one entry for the batch.
389+
log.info("Enqueueing '{}' evaluation messages, one per thread ID, for rule '{}'",
390+
threadIds.size(), rule.getId());
389391
return onlineScorePublisher.enqueueThreadMessage(threadIds, rule, projectId,
390392
workspaceId, userName);
391393
})

0 commit comments

Comments
 (0)