Skip to content

Commit d8ffd35

Browse files
thiagohoraclaude
andcommitted
test: drive a real StreamConstraintsException through a real stream end to end
You were right to push on this, and my earlier wording was wrong. I said the Exception arm "has always handled the literal incident" -- I meant since the first commit OF THIS PR, which introduced the wrapper. Pre-PR there was no try/catch in the codec at all, so the StreamConstraintsException propagated up through CommandDecoder exactly as the original OPIK-8164 analysis said. Nothing of ours caught it. And the drop mechanism was not actually tested for a real decode failure. Every existing test either injected a pre-built UndecodableStreamMessage into a mocked readGroup, or exercised the decoder in isolation. None reproduced the thing that happened: written successfully, then failing on the way back out below the subscriber. New BaseRedisSubscriberTest.UndecodablePayloadTests does that on real Redis -- a JAVA-shaped codec with a 512-byte string limit standing in for production's 100 MB, so the write/read asymmetry reproduces at a few hundred bytes. It asserts the entry writes fine, the healthy entry behind it still processes, the undecodable one is NOT removed on first delivery, and that it does leave once the delivery count reaches maxRetries. Mutation-checked, and the mutant is the incident: making tolerant() a no-op produces io.netty.handler.codec.DecoderException: com.fasterxml.jackson.core.exc.StreamConstraintsException: String value length (513) exceeds the maximum allowed (512, ...) which is the production stack trace's exact shape, and the test fails at the FIRST await -- the healthy entry behind it never processes either. That is the batch-stranding half of the wedge, now covered. Two things the run exposed, both documented in the test: - Retirement is driven by the delivery count, which only advances when XAUTOCLAIM redelivers after pendingMessageDuration. At the shipped online-scoring values (10m, maxRetries 3) an undecodable entry sits in the stream for ~30 minutes, re-read and re-decoded each cycle, before removal. Bounded, not eliminated -- and at production payload sizes each cycle re-attempts a large materialization. The test compresses the timers to 500ms/2 to stay fast. - My first attempt at this test published before subscriber.start(), so the consumer group was created at '$' and never saw the entries. That was a test bug, not a code one, but it is why the ordering is now explicit with a comment. RedisStreamCodec.faultTolerant widens from package-private to public @VisibleForTesting so the subscriber test package can build the codec. 59 green, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7cef0b8 commit d8ffd35

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

apps/opik-backend/src/main/java/com/comet/opik/infrastructure/redis/RedisStreamCodec.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ static ObjectMapper buildStreamMapper() {
6363
* directions: a pod on either build writes what the other can read.
6464
*/
6565
@VisibleForTesting
66-
static Codec faultTolerant(Codec delegate) {
66+
public static Codec faultTolerant(Codec delegate) {
6767
return new FaultTolerantCodec(delegate);
6868
}
6969

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.comet.opik.api.resources.v1.events;
22

33
import com.comet.opik.api.resources.utils.RedisContainerUtils;
4+
import com.comet.opik.infrastructure.redis.RedisStreamCodec;
45
import com.comet.opik.podam.PodamFactoryUtils;
56
import com.comet.opik.utils.JsonUtils;
7+
import com.fasterxml.jackson.core.StreamReadConstraints;
68
import com.redis.testcontainers.RedisContainer;
79
import jakarta.ws.rs.ClientErrorException;
810
import jakarta.ws.rs.NotFoundException;
@@ -22,7 +24,10 @@
2224
import org.redisson.api.stream.StreamCreateGroupArgs;
2325
import org.redisson.api.stream.StreamMessageId;
2426
import org.redisson.api.stream.StreamReadGroupArgs;
27+
import org.redisson.client.codec.Codec;
28+
import org.redisson.codec.CompositeCodec;
2529
import org.redisson.codec.JsonJacksonCodec;
30+
import org.redisson.codec.LZ4CodecV2;
2631
import org.redisson.config.Config;
2732
import reactor.core.publisher.Flux;
2833
import reactor.core.publisher.Mono;
@@ -89,6 +94,99 @@ void tearDown() {
8994
subscribers.forEach(BaseRedisSubscriber::stop);
9095
}
9196

97+
/**
98+
* The OPIK-8164 mechanism end to end, on real Redis, with a real decode failure.
99+
* <p>
100+
* Every other drop test in this PR either injects a pre-built {@code UndecodableStreamMessage} into
101+
* a mocked {@code readGroup}, or exercises the decoder in isolation. Neither reproduces the thing
102+
* that actually happened: the payload is written successfully, because Jackson has no
103+
* serialization-side limit, and then breaches {@code maxStringLength} on the way back out — inside
104+
* Redisson's {@code CommandDecoder}, below {@code BaseRedisSubscriber}, which pre-PR is exactly why
105+
* the entry could never be acked and the stream wedged permanently.
106+
* <p>
107+
* A small {@code maxStringLength} stands in for production's 100 MB so the asymmetry is reproduced
108+
* at a few hundred bytes instead of tens of megabytes. The write path is unbounded either way, so
109+
* the mechanism is identical.
110+
* <p>
111+
* Worth knowing what this run exposed about timing, because the retry timers are compressed here
112+
* and are not in production. Retirement is driven by the delivery count, which only advances when
113+
* {@code XAUTOCLAIM} redelivers the entry after {@code pendingMessageDuration}. At the shipped
114+
* online-scoring values ({@code pendingMessageDuration: 10m}, {@code maxRetries: 3}) an undecodable
115+
* entry therefore stays in the stream for roughly 30 minutes, being re-read and re-decoded on each
116+
* cycle, before it is removed. The wedge is bounded rather than eliminated — and for a payload of
117+
* production size, each cycle re-attempts a large materialization. That is the cost of not deleting
118+
* on first delivery; it is the right trade while a newer pod might still decode the entry, but it
119+
* is not free.
120+
*/
121+
@Nested
122+
class UndecodablePayloadTests {
123+
124+
private static final int SMALL_STRING_LIMIT = 512;
125+
126+
private TestStreamConfiguration smallLimitConfig;
127+
private RStreamReactive<String, String> smallLimitStream;
128+
129+
/** The JAVA codec's shape, but with a string limit small enough to breach cheaply. */
130+
private Codec smallLimitCodec() {
131+
var mapper = JsonUtils.getMapper().copy();
132+
mapper.getFactory().setStreamReadConstraints(
133+
StreamReadConstraints.builder().maxStringLength(SMALL_STRING_LIMIT).build());
134+
return RedisStreamCodec.faultTolerant(
135+
new CompositeCodec(new LZ4CodecV2(), new JsonJacksonCodec(mapper)));
136+
}
137+
138+
@BeforeEach
139+
void setUp() {
140+
smallLimitConfig = TestStreamConfiguration.create().toBuilder()
141+
.codec(smallLimitCodec())
142+
// Retirement is driven by the delivery count, which only rises when XAUTOCLAIM
143+
// redelivers the entry after pendingMessageDuration. The class default is 2 minutes
144+
// and maxRetries 3, so an undecodable entry would sit in the stream for ~6 minutes.
145+
// Compressed here to keep the test quick; see the class javadoc for what this means
146+
// at the shipped values.
147+
.pendingMessageDuration(io.dropwizard.util.Duration.milliseconds(500))
148+
.claimIntervalRatio(2)
149+
.maxRetries(2)
150+
.build();
151+
smallLimitStream = redissonClient.getStream(
152+
smallLimitConfig.getStreamName(), smallLimitConfig.getCodec());
153+
smallLimitStream.delete().block();
154+
}
155+
156+
@Test
157+
void shouldDrainAnEntryThatOnlyFailsOnRead() {
158+
var subscriber = trackSubscriber(
159+
TestRedisSubscriber.createSubscriber(smallLimitConfig, redissonClient));
160+
var oversized = "a".repeat(SMALL_STRING_LIMIT + 1);
161+
var healthy = "well-within-limits";
162+
163+
// Start first: the consumer group is created at '$', so it only sees entries added after
164+
// start(). Publishing beforehand leaves them permanently undelivered and proves nothing.
165+
subscriber.start();
166+
167+
// Writes fine: Jackson constrains reads, not writes. This is the asymmetry behind OPIK-8164.
168+
smallLimitStream.add(StreamAddArgs.entry(TestStreamConfiguration.PAYLOAD_FIELD, oversized)).block();
169+
smallLimitStream.add(StreamAddArgs.entry(TestStreamConfiguration.PAYLOAD_FIELD, healthy)).block();
170+
assertThat(smallLimitStream.size().block()).isEqualTo(2);
171+
172+
// The healthy entry behind it still gets through -- pre-PR it was stranded in the same batch.
173+
await().atMost(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
174+
.untilAsserted(() -> assertThat(subscriber.getSuccessMessageCount().get()).isEqualTo(1));
175+
176+
// The undecodable entry is NOT gone yet: it is retryable, so it stays pending for another
177+
// consumer rather than being deleted on sight.
178+
assertThat(smallLimitStream.size().block()).isEqualTo(1);
179+
180+
// And it does eventually leave, once the delivery count reaches maxRetries -- bounded, not
181+
// permanent. This is the half that distinguishes this fix from the pre-PR wedge.
182+
await().atMost(AWAIT_TIMEOUT_SECONDS * 10, TimeUnit.SECONDS)
183+
.untilAsserted(() -> assertThat(smallLimitStream.size().block()).isZero());
184+
185+
// It never reached processEvent: the sentinel is intercepted in processMessage.
186+
assertThat(subscriber.getFailedMessageCount().get()).isZero();
187+
}
188+
}
189+
92190
@Nested
93191
class SuccessTests {
94192

0 commit comments

Comments
 (0)