Commit df66b23
[OPIK-8240] [BE] fix: Resume XAUTOCLAIM from its cursor (#8137)
* [OPIK-8240] [BE] fix: split provider-error retryability by status, resume XAUTOCLAIM from its cursor
Two independent defects that each leave a permanently failing online-scoring
message cycling instead of retiring it.
1. A permanent provider 4xx was retried as if transient.
ChatCompletionService.scoreTrace answered every unmappable provider failure
with a blanket InternalServerErrorException, which sits outside
BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS -- so a request that can never
succeed was replayed maxRetries times, once per pendingMessageDuration. The
real status was available the whole time on the HttpException in the cause
chain; it went unread because the existing recovery path classifies by family,
and mapping all of 4xx to ClientErrorException would drop transient 408/429
after one attempt.
Classify by status code instead: 400/401/403 and the rest of 4xx are
permanent, 408/425/429 and all 5xx stay retryable. scoreTrace gets its own
mapping so create() and the streaming handler keep returning the provider's
status verbatim to HTTP callers. This also closes a pre-existing over-eager
drop on the parseable branch, where a JSON-parseable 429 already became
ClientErrorException and was dropped after one attempt.
2. XAUTOCLAIM never scanned past the first ~100 pending entries.
claimPendingMessages passed StreamMessageId.MIN as the scan start every call
and discarded the cursor Redis returns. Redis caps each XAUTOCLAIM at
COUNT * 10 PEL entries *examined*, so at consumerBatchSize=10 a call inspects
only the first 100 -- and restarting at MIN means nothing past that window is
ever examined. Any backlog above ~100 grows a permanently unreachable tail.
Carry getNextId() forward, resetting to MIN on Redis's 0-0 end-of-pass reply.
A failed scan deliberately does not advance the cursor, so its window is
retried rather than skipped. The sentinel is compared numerically:
StreamMessageId.MIN/.ALL serialize to "-" and "0" and neither is equals() to
the StreamMessageId(0, 0) Redisson parses 0-0 into (verified against redisson
4.7.0), so matching on the constants would never fire.
Addressed from review:
- Status precedence was backwards. The provider mappers synthesize a status
when they cannot read one off the body (CustomLlmErrorMessage defaults to
400, OpenAiErrorMessage to 500), and preferring the mapped code let a
synthetic 400 mask a real upstream 503 -- classifying a transient failure as
permanent and dropping it on first delivery. The cause-chain HttpException
now wins, with the mapped code as fallback, matching the precedence
findProviderHttpStatus already documents for its own chain walk.
- Sibling aggregation could discard retryable work. The trace-thread scorers
re-emit one error for a message that fans out over many thread ids, and
errors.getFirst() made that an arrival-order race. Harmless while every
failure was a blanket 500; not once permanent and transient were split, as a
ClientErrorException arriving first would ack and remove the entry with its
retryable siblings. Both scorers now share representativeError(), which
prefers a retryable sibling: a bounded replay is recoverable, silently
dropped work is not.
- Test-only: unconditional assertion flow in the parameterized status test,
real-mapper-path coverage for the precedence rule, display-name wording.
Tests: 145 green across the affected suites. Mutation-checked -- collapsing
the status split to the whole 4xx family fails 5, removing it fails 7,
reverting the cursor to always-MIN fails 3, inverting the status precedence
fails 4, reverting aggregation to getFirst() fails 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct the OOM-arm discharge in RedisStreamCodec — no publish-time guard ships
Folded in from #8145 (closed in favour of carrying it here), addressing review
feedback on #8089 that landed after that PR merged.
The javadoc justified absorbing an OutOfMemoryError by pointing at a
publish-time size guard as the control covering the residual risk, naming
onlineScoring.dropOversizedPayloads. That key does not exist: a repo-wide grep
returns exactly one hit, the sentence itself.
It never shipped. #8060 was merged as the Guice/Dropwizard ordering fix that
made the codec actually receive maxStringLength; the publisher-side drop guard
it originally carried was cut from that PR before merge, under this or any
other name.
That matters more than a stale reference normally would, because of the job
the sentence was doing. The paragraph above it makes the most serious
admission in the design -- under real heap pressure this arm absorbs an OOM
that was a symptom rather than a cause and keeps consuming, masking it -- and
this sentence was its discharge. With no such guard the risk is open, not
delegated, and someone auditing the decision later would go looking for a
control that was never built.
Wording as suggested in review. Comment-only: the OOM-absorbing call itself is
unchanged and not in question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: fold sibling failures pairwise instead of collecting them
Second review round on OPIK-8240.
Fan-out is not chunked at either call site -- a manual evaluation passes every
resolved thread id, and the streaming path every thread that closed in the
window -- so collectList() could hold every sibling Throwable and its cause
chain in memory at once during a provider outage, only to discard all but one.
Reduced pairwise instead, keeping a single accumulator, in both trace-thread
scorers. It also drops the isEmpty() check and the second pass: an empty Flux
reduces to an empty Mono, which is already the no-failures case.
Selection semantics are unchanged -- first retryable wins, else first failure.
The trade is that the count of failed siblings is no longer recoverable for
reporting; nothing reports it today.
Tests: the two order-variant cases are consolidated into one @ParameterizedTest
driven through Flux.reduce, so they exercise the accumulator the way the
scorers actually use it rather than by direct call, and cover the empty
sequence. Added a case pinning order-stability across three siblings, which the
previous pair could not distinguish -- mutation-checked: always keeping the
incumbent fails 2, always preferring a retryable candidate fails 1.
Also: cursor test asserts isEqualTo rather than isSameAs. The contract is that
the position is carried forward, not the identity of the object carrying it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: make the no-failures case a plain @test
Parameterizing a single fixed case bought nothing and left an unused testName
parameter -- an artefact of mechanically applying the same shape as the
consolidated ordering cases, which do have something to vary. Inlined as
Flux.empty(), which also says what the case is more directly than an empty
list threaded through a MethodSource.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: cover the claim cursor on real redis, drop method-only test classes
Review feedback on #8137, from andrescrz.
The claim cursor was pinned by mocked XAUTOCLAIM replies plus a
class-per-method test of the mapping function. Neither proves the
behaviour: the reply those tests assert on is the one the test itself
wrote. Redis's own PEL scan is what produces it in production, and its
COUNT * 10 examine budget is the whole reason the cursor exists.
BaseRedisSubscriberTest now drives a pending list deeper than one scan
window against the real container. Restoring StreamMessageId.MIN makes
it time out on the tail, which is the regression it is there to catch.
The two mocked unit tests it subsumes are gone; the failed-scan one
stays, because a container cannot be made to fail one XAUTOCLAIM and
succeed on the next.
The scoreTrace cases carried a helper that branched on
isPermanentFailure to pick its assertions -- a test deriving its
expectation from the classifier under test cannot fail when the
classifier is wrong. Split into assertNonRetryable / assertRetryable,
with each parameterised case running one unconditional flow over rows
partitioned by a literal status set. That keeps the no-branching shape
an earlier review asked for. Also restores the status assertion the
transient case had lost, adds the permanent half of the parseable
branch, turns the isPermanentFailure table into input-vs-expected rows,
and randomises the request and workspace the helper builds.
nextCursor goes private rather than gaining @VisibleForTesting: with
its only direct test removed, there is no test for the annotation to
document. isPermanentFailure, which is still called directly, gets the
annotation in place of its comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* narrow to the claim-cursor fix; move retry classification to its own PR
Per review: andrescrz's feedback was mostly about the retry logic, so the two
fixes are being separated rather than reviewed together.
Removed from this PR, moved to a follow-up:
- ChatCompletionService's status-code retryability split and its tests.
Removed entirely, filed as OPIK-8262:
- preferRetryable / the sibling-failure aggregation change. The real fix is
refactoring the trace-thread scorers to emit per-message ProcessingResults so
the base subscriber's existing per-message ack/remove granularity is used,
rather than picking a less-bad victim from a collapsed batch. Restored
errors.getFirst(), which is what main already did -- the defect pre-dates
this PR.
Also dropped the RedisStreamCodec javadoc correction that had been folded in
from #8145: it belongs to #8089, which is already merged, and does not need to
ride along here.
What remains is the XAUTOCLAIM cursor fix alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: reset the claim cursor whenever the consumer group is recreated
recoverFromNoGroup is reached from both readMessages and
claimPendingMessages, but the cursor reset sat at the claim call site only.
The read path is the likelier of the two to notice NOGROUP first, since reads
run on every tick that is not a claim tick -- so the common case left the
cursor pointing into the pending list of a group that no longer exists.
Not self-correcting on a busy stream. A scan starting above the recreated
PEL's entries only wraps once it exhausts the list, and entries arriving after
the stale position keep giving it work at the high end, so the wrap can be
deferred indefinitely while the oldest entries go unexamined -- the exact
starvation this PR exists to remove.
Reset moved into recoverFromNoGroup so both paths get identical treatment and
there is one place that owns it.
Regression test covers the read path specifically. Mutation-checked: moving
the reset back to the claim site alone fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>1 parent 9f7ba31 commit df66b23
3 files changed
Lines changed: 277 additions & 2 deletions
File tree
- apps/opik-backend/src
- main/java/com/comet/opik/api/resources/v1/events
- test/java/com/comet/opik/api/resources/v1/events
Lines changed: 65 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
127 | 127 | | |
128 | 128 | | |
129 | 129 | | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
130 | 151 | | |
131 | 152 | | |
132 | 153 | | |
| |||
398 | 419 | | |
399 | 420 | | |
400 | 421 | | |
401 | | - | |
| 422 | + | |
402 | 423 | | |
403 | 424 | | |
404 | 425 | | |
405 | | - | |
| 426 | + | |
406 | 427 | | |
407 | 428 | | |
408 | 429 | | |
| |||
412 | 433 | | |
413 | 434 | | |
414 | 435 | | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
415 | 439 | | |
416 | 440 | | |
417 | 441 | | |
| |||
420 | 444 | | |
421 | 445 | | |
422 | 446 | | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
423 | 476 | | |
424 | 477 | | |
425 | 478 | | |
| |||
453 | 506 | | |
454 | 507 | | |
455 | 508 | | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
456 | 519 | | |
457 | 520 | | |
458 | 521 | | |
| |||
Lines changed: 103 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
| |||
429 | 430 | | |
430 | 431 | | |
431 | 432 | | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
432 | 535 | | |
433 | 536 | | |
434 | 537 | | |
| |||
Lines changed: 109 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
622 | 622 | | |
623 | 623 | | |
624 | 624 | | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
625 | 677 | | |
626 | 678 | | |
627 | 679 | | |
| |||
767 | 819 | | |
768 | 820 | | |
769 | 821 | | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
770 | 879 | | |
771 | 880 | | |
772 | 881 | | |
| |||
0 commit comments