Skip to content

Commit df66b23

Browse files
thiagohoraclaude
andauthored
[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/BaseRedisSubscriber.java

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,27 @@ public abstract class BaseRedisSubscriber<M> implements Managed {
127127
private final LongHistogram listPendingTime;
128128
private final LongCounter unexpectedErrors;
129129

130+
/**
131+
* Resume point for the next {@code XAUTOCLAIM} scan.
132+
*
133+
* <p>Redis caps each {@code XAUTOCLAIM} at {@code COUNT * 10} PEL entries <em>examined</em> (not
134+
* claimed), so with the default {@code consumerBatchSize} of 10 a single call only ever inspects the
135+
* first 100 entries of the pending list. Restarting every scan at {@link StreamMessageId#MIN} —
136+
* which this class used to do, discarding the cursor Redis hands back — means anything past that
137+
* first window is never examined at all: a backlog above ~100 entries develops a permanently
138+
* unreachable tail, whatever the retry or decode behaviour further down. Carrying the returned
139+
* cursor forward walks the whole PEL instead, a window at a time.
140+
*
141+
* <p>Redis returns {@code 0-0} once a pass has covered the entire pending list; that resets this
142+
* back to {@link StreamMessageId#MIN} so the next pass starts over from the oldest entry, keeping
143+
* the oldest-first bias the original code intended.
144+
*
145+
* <p>Volatile rather than synchronized: {@code concatMap} in {@link #setupStreamListener()} already
146+
* serializes claim calls, so this is only ever written by one claim at a time; volatile just
147+
* publishes that write to whichever scheduler thread runs the next one.
148+
*/
149+
private volatile StreamMessageId claimCursor = StreamMessageId.MIN;
150+
130151
private volatile RStreamReactive<String, M> stream;
131152
private volatile Disposable streamSubscription;
132153
private volatile Scheduler timerScheduler;
@@ -398,11 +419,11 @@ private Mono<Map<StreamMessageId, Map<String, M>>> claimPendingMessages() {
398419
consumerId,
399420
config.getPendingMessageDuration().toJavaDuration().toMillis(),
400421
TimeUnit.MILLISECONDS,
401-
StreamMessageId.MIN, // Start from the beginning of pending list
422+
claimCursor, // Resume where the previous scan stopped; see claimCursor
402423
config.getConsumerBatchSize())
403424
.subscribeOn(consumerScheduler)
404425
.filter(Objects::nonNull)
405-
.map(AutoClaimResult::getMessages)
426+
.map(this::advanceCursorAndExtractMessages)
406427
.filter(Objects::nonNull)
407428
.doOnSuccess(claimedMessages -> {
408429
claimedMessages = Objects.requireNonNullElse(claimedMessages, Map.of());
@@ -412,6 +433,9 @@ private Mono<Map<StreamMessageId, Map<String, M>>> claimPendingMessages() {
412433
.onErrorResume(throwable -> {
413434
claimErrors.add(1);
414435
log.error("Error claiming pending messages", throwable);
436+
// A failed scan leaves the cursor where it was, so the next attempt retries the same
437+
// window rather than skipping it. The NOGROUP case is different, but recoverFromNoGroup
438+
// resets the cursor itself so both it and the read path get the same treatment.
415439
if (isNoGroupError(throwable)) {
416440
return recoverFromNoGroup();
417441
}
@@ -420,6 +444,35 @@ private Mono<Map<StreamMessageId, Map<String, M>>> claimPendingMessages() {
420444
.doFinally(signalType -> claimTime.record(System.currentTimeMillis() - startMillis));
421445
}
422446

447+
/**
448+
* Advances {@link #claimCursor} to where Redis says the next scan should resume, then returns the
449+
* batch this scan actually claimed.
450+
*
451+
* <p>A {@code null} or {@code 0-0} next-id means the pass reached the end of the pending list, so the
452+
* cursor goes back to {@link StreamMessageId#MIN} and the following pass starts from the oldest entry
453+
* again.
454+
*/
455+
private Map<StreamMessageId, Map<String, M>> advanceCursorAndExtractMessages(AutoClaimResult<String, M> result) {
456+
claimCursor = nextCursor(result.getNextId());
457+
return result.getMessages();
458+
}
459+
460+
/**
461+
* The cursor to use for the next scan, given the next-id Redis returned.
462+
*
463+
* <p>Compared numerically rather than against {@link StreamMessageId#MIN} / {@link StreamMessageId#ALL}:
464+
* those are wire sentinels that serialize to {@code -} and {@code 0}, and neither is
465+
* {@code equals()} to the {@code StreamMessageId(0, 0)} that Redisson parses Redis's literal
466+
* {@code 0-0} end-of-pass reply into. Matching on the constants would therefore never fire, and the
467+
* scan would run off the end of the PEL and stay there instead of wrapping.
468+
*/
469+
private static StreamMessageId nextCursor(StreamMessageId nextId) {
470+
if (nextId == null || (nextId.getId0() == 0 && nextId.getId1() == 0)) {
471+
return StreamMessageId.MIN;
472+
}
473+
return nextId;
474+
}
475+
423476
private Mono<Map<StreamMessageId, Map<String, M>>> readMessages() {
424477
var startMillis = System.currentTimeMillis();
425478
var streamReadGroupArgs = StreamReadGroupArgs.neverDelivered()
@@ -453,6 +506,16 @@ private boolean isNoGroupError(Throwable throwable) {
453506
private Mono<Map<StreamMessageId, Map<String, M>>> recoverFromNoGroup() {
454507
log.warn("Recreating not found consumer group '{}' for stream '{}'",
455508
config.getConsumerGroupName(), config.getStreamName());
509+
// The cursor indexes the OLD group's pending list, so it means nothing once the group is
510+
// recreated. Reset here rather than at the call sites: NOGROUP surfaces from both readMessages
511+
// and claimPendingMessages, and the read path is the likelier of the two to notice it first
512+
// (reads run on every tick that is not a claim tick).
513+
//
514+
// Leaving it stale is not self-correcting on a busy stream. A scan starting above the recreated
515+
// PEL's entries only wraps once it exhausts the list, and entries arriving after the stale
516+
// position keep giving it work at the high end -- so the wrap can be deferred indefinitely while
517+
// the oldest entries, the ones this scan exists to reach, are never examined.
518+
claimCursor = StreamMessageId.MIN;
456519
return createConsumerGroup()
457520
.onErrorResume(throwable -> {
458521
log.error("Failed to recreate consumer group '{}' for stream '{}'",

apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/events/BaseRedisSubscriberTest.java

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import java.util.concurrent.CopyOnWriteArraySet;
4545
import java.util.concurrent.TimeUnit;
4646
import java.util.concurrent.atomic.AtomicInteger;
47+
import java.util.stream.IntStream;
4748
import java.util.stream.Stream;
4849

4950
import static com.comet.opik.api.resources.utils.TestUtils.waitForMillis;
@@ -429,6 +430,108 @@ void shouldRecoverFromNoGroupOnReadAndContinueProcessing() {
429430
}
430431
}
431432

433+
/**
434+
* OPIK-8240, against a real Redis PEL deeper than one {@code XAUTOCLAIM} scan window.
435+
* <p>
436+
* Redis caps each {@code XAUTOCLAIM} at {@code COUNT * 10} pending entries <em>examined</em> — not
437+
* claimed — and answers with the position the next scan should resume from. Discarding that position
438+
* and restarting every scan at {@link StreamMessageId#MIN} is invisible while the head of the PEL
439+
* drains, because the entries that leave make room in the window. It stops being invisible as soon
440+
* as the head stops draining: the budget is then spent re-examining the same first {@code COUNT * 10}
441+
* entries and everything behind them is never looked at again, whatever the retry behaviour further
442+
* down.
443+
* <p>
444+
* Both halves of the cursor contract are observable here, and only on a real Redis: the reply that
445+
* this class consumes is produced by Redis's own PEL scan, not by anything the subscriber controls.
446+
*/
447+
@Nested
448+
class ClaimCursorTests {
449+
450+
// Gives XAUTOCLAIM a 100-entry examine budget per scan (COUNT * 10).
451+
private static final int CLAIM_BATCH_SIZE = 10;
452+
private static final int SCAN_WINDOW = CLAIM_BATCH_SIZE * 10;
453+
// Deliberately deeper than one window: the last 50 are the entries a cursor-less scan strands.
454+
private static final int BACKLOG_SIZE = SCAN_WINDOW + 50;
455+
private static final int CLAIM_TIMEOUT_SECONDS = 30;
456+
457+
private TestStreamConfiguration deepBacklogConfig;
458+
private RStreamReactive<String, String> deepBacklogStream;
459+
460+
@BeforeEach
461+
void setUp() {
462+
deepBacklogConfig = TestStreamConfiguration.create().toBuilder()
463+
.consumerBatchSize(CLAIM_BATCH_SIZE)
464+
// Every poll claims. Nothing is published after start(), so a read would only park the
465+
// concatMap on its long poll and stretch the run out for no coverage.
466+
.claimIntervalRatio(1)
467+
.pendingMessageDuration(io.dropwizard.util.Duration.milliseconds(100))
468+
// High enough that nothing retires mid-run. Retirement removes entries from the PEL,
469+
// which would let even a cursor-less scan crawl to the tail eventually and pass this
470+
// test for the wrong reason.
471+
.maxRetries(Integer.MAX_VALUE)
472+
.build();
473+
deepBacklogStream = redissonClient.getStream(
474+
deepBacklogConfig.getStreamName(), deepBacklogConfig.getCodec());
475+
deepBacklogStream.delete().block();
476+
}
477+
478+
@Test
479+
void shouldReachPendingEntriesBeyondTheFirstScanWindowAndThenWrap() {
480+
var messages = IntStream.range(0, BACKLOG_SIZE)
481+
.mapToObj(index -> "backlog-%03d-%s".formatted(index, UUID.randomUUID()))
482+
.toList();
483+
deepBacklogStream.createGroup(
484+
StreamCreateGroupArgs.name(deepBacklogConfig.getConsumerGroupName()).makeStream()).block();
485+
486+
// concatMap, not flatMap: stream ids must follow publication order for "the head of the PEL"
487+
// to mean the first element of this list.
488+
Flux.fromIterable(messages)
489+
.concatMap(message -> deepBacklogStream.add(
490+
StreamAddArgs.entry(TestStreamConfiguration.PAYLOAD_FIELD, message)))
491+
.collectList()
492+
.block();
493+
494+
// Delivered to a consumer that never acks, which is the backlog a crashed or restarted pod
495+
// leaves behind: XLEN and the PEL both hold the full depth.
496+
var crashedConsumerId = "crashed-consumer-%s".formatted(UUID.randomUUID());
497+
var delivered = deepBacklogStream.readGroup(
498+
deepBacklogConfig.getConsumerGroupName(), crashedConsumerId,
499+
StreamReadGroupArgs.neverDelivered()
500+
.count(BACKLOG_SIZE)
501+
.timeout(deepBacklogConfig.getLongPollingDuration().toJavaDuration()))
502+
.block();
503+
assertThat(delivered).hasSize(BACKLOG_SIZE);
504+
505+
// Idle for longer than min-idle-time, so every entry is claimable rather than skipped.
506+
waitForMillis(deepBacklogConfig.getPendingMessageDuration().toMilliseconds() + 100);
507+
508+
// Every delivery fails retryably, so nothing is acked and the PEL keeps its full depth for the
509+
// whole run — the condition that makes the scan window a ceiling rather than a batch size.
510+
var deliveries = new ConcurrentHashMap<String, AtomicInteger>();
511+
var subscriber = trackSubscriber(TestRedisSubscriber.createSubscriber(
512+
deepBacklogConfig, redissonClient, message -> {
513+
deliveries.computeIfAbsent(message, key -> new AtomicInteger()).incrementAndGet();
514+
return Mono.error(new RuntimeException("Retryable, so the entry stays pending"));
515+
}));
516+
subscriber.start();
517+
518+
// The tail is reached. Without the cursor this stalls at the first SCAN_WINDOW entries: each
519+
// scan restarts at MIN, spends its whole examine budget on the head, and returns having never
520+
// looked further.
521+
await().atMost(CLAIM_TIMEOUT_SECONDS, TimeUnit.SECONDS)
522+
.untilAsserted(() -> assertThat(deliveries.keySet())
523+
.containsExactlyInAnyOrderElementsOf(messages));
524+
525+
// And the scan wraps instead of parking at the end of the PEL. Redis answers a completed pass
526+
// with 0-0, which resets the cursor to the oldest entry; without that reset the oldest entry
527+
// would never be delivered a second time and the subscriber would go quiet on a PEL that is
528+
// still full.
529+
await().atMost(CLAIM_TIMEOUT_SECONDS, TimeUnit.SECONDS)
530+
.untilAsserted(() -> assertThat(deliveries.get(messages.getFirst()).get())
531+
.isGreaterThan(1));
532+
}
533+
}
534+
432535
@Nested
433536
class RetryTests {
434537

apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/events/BaseRedisSubscriberUnitTest.java

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,58 @@ void shouldHandleInvalidMessageIdTimestamp() {
622622
@Nested
623623
class NoGroupErrorTests {
624624

625+
/**
626+
* Regression for review feedback on OPIK-8240: {@code recoverFromNoGroup} is reached from BOTH
627+
* {@code readMessages} and {@code claimPendingMessages}, and the cursor reset was originally only
628+
* at the claim call site. The read path is the likelier of the two to notice NOGROUP first, since
629+
* reads run on every tick that is not a claim tick.
630+
*
631+
* <p>A cursor left pointing into the old group's pending list is not self-correcting on a busy
632+
* stream: a scan starting above the recreated PEL's entries only wraps once it exhausts the list,
633+
* and entries arriving after the stale position keep feeding it at the high end, so the oldest
634+
* entries can go unexamined indefinitely -- the exact starvation this fix exists to remove.
635+
*/
636+
@Test
637+
void shouldResetTheClaimCursorWhenTheGroupIsRecreatedViaTheReadPath() {
638+
whenCreateGroupReturnEmpty();
639+
whenRemoveConsumerReturn();
640+
var fastConfig = CONFIG.toBuilder().claimIntervalRatio(2).build();
641+
var subscriber = trackSubscriber(TestRedisSubscriber.createSubscriber(fastConfig, redissonClient));
642+
var starts = new CopyOnWriteArrayList<StreamMessageId>();
643+
var claims = new AtomicInteger();
644+
645+
// First claim advances the cursor well past the start of the PEL.
646+
when(stream.autoClaim(
647+
eq(fastConfig.getConsumerGroupName()),
648+
anyString(),
649+
eq(fastConfig.getPendingMessageDuration().toJavaDuration().toMillis()),
650+
eq(TimeUnit.MILLISECONDS),
651+
any(StreamMessageId.class),
652+
eq(fastConfig.getConsumerBatchSize())))
653+
.thenAnswer(invocation -> {
654+
starts.add(invocation.getArgument(4));
655+
claims.incrementAndGet();
656+
return Mono.just(new AutoClaimResult<>(
657+
new StreamMessageId(9_000L, 0), Map.of(), List.of()));
658+
});
659+
// Reads then hit NOGROUP, which recreates the group underneath us.
660+
when(stream.readGroup(eq(fastConfig.getConsumerGroupName()), anyString(),
661+
any(StreamReadGroupArgs.class)))
662+
.thenAnswer(invocation -> Mono
663+
.error(new RuntimeException("NOGROUP No such key stream or consumer group")));
664+
whenAckReturn();
665+
whenRemoveReturn();
666+
667+
subscriber.start();
668+
669+
await().atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS).until(() -> starts.size() >= 2);
670+
671+
// The recreated group has a fresh PEL, so the next scan must start from the beginning rather
672+
// than from the position it reached in the group that no longer exists.
673+
assertThat(starts.getFirst()).isEqualTo(StreamMessageId.MIN);
674+
assertThat(starts.get(1)).isEqualTo(StreamMessageId.MIN);
675+
}
676+
625677
@Test
626678
void shouldRecoverOnClaimAndNotDie() {
627679
whenCreateGroupReturnEmpty();
@@ -767,6 +819,63 @@ private static PendingEntry pendingEntry(StreamMessageId messageId, long deliver
767819
return entry;
768820
}
769821

822+
/**
823+
* OPIK-8240, for the one part of the claim cursor a real Redis cannot be made to produce on demand:
824+
* a failing {@code XAUTOCLAIM}.
825+
* <p>
826+
* The cursor's normal behaviour -- walking past the first {@code COUNT * 10} examine window and
827+
* wrapping when Redis reports the pass complete -- is covered end to end in
828+
* {@link BaseRedisSubscriberTest.ClaimCursorTests} against a PEL deeper than that window, so it is
829+
* deliberately not duplicated here. What is left is the error path: the cursor must NOT advance past
830+
* a window whose scan failed, or that window's entries are skipped until the next full wrap. Making
831+
* a real Redis fail one XAUTOCLAIM mid-run and succeed on the next is not something the container
832+
* exposes, so it is mocked.
833+
*/
834+
@Nested
835+
class ClaimCursorTests {
836+
837+
@BeforeEach
838+
void setUp() {
839+
whenCreateGroupReturnEmpty();
840+
whenRemoveConsumerReturn();
841+
}
842+
843+
@Test
844+
void shouldRetryTheSameWindowAfterAFailedScan() {
845+
var fastConfig = CONFIG.toBuilder().claimIntervalRatio(2).build();
846+
var subscriber = trackSubscriber(TestRedisSubscriber.createSubscriber(fastConfig, redissonClient));
847+
var starts = new CopyOnWriteArrayList<StreamMessageId>();
848+
var calls = new AtomicInteger();
849+
when(stream.autoClaim(
850+
eq(fastConfig.getConsumerGroupName()),
851+
anyString(),
852+
eq(fastConfig.getPendingMessageDuration().toJavaDuration().toMillis()),
853+
eq(TimeUnit.MILLISECONDS),
854+
any(StreamMessageId.class),
855+
eq(fastConfig.getConsumerBatchSize())))
856+
.thenAnswer(invocation -> {
857+
starts.add(invocation.getArgument(4));
858+
if (calls.incrementAndGet() == 1) {
859+
return Mono.just(new AutoClaimResult<>(
860+
new StreamMessageId(700L, 0), Map.of(), List.of()));
861+
}
862+
// A failed scan must not advance the cursor, or the window it covered would be
863+
// skipped entirely and its entries left unreachable until the next full wrap.
864+
return Mono.error(new RuntimeException("Redis autoClaim error"));
865+
});
866+
whenReadGroupReturnMessages();
867+
whenAckReturn();
868+
whenRemoveReturn();
869+
870+
subscriber.start();
871+
872+
await().atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS).until(() -> starts.size() >= 3);
873+
874+
assertThat(starts.get(1)).isEqualTo(new StreamMessageId(700L, 0));
875+
assertThat(starts.get(2)).isEqualTo(new StreamMessageId(700L, 0));
876+
}
877+
}
878+
770879
private TestRedisSubscriber trackSubscriber(TestRedisSubscriber subscriber) {
771880
subscribers.add(subscriber);
772881
return subscriber;

0 commit comments

Comments
 (0)