Skip to content

Commit d7dc961

Browse files
thiagohoraclaude
andcommitted
refactor: absorb OutOfMemoryError instead of pre-checking the LZ4 declared length
Replaces LengthBoundedLz4Codec (net -102 lines). The ceiling it enforced was an undocumented number of my own choosing: nothing in Redis or Redisson constrains a stream field name, so 4096 rested only on the local observation that every field name in this codebase is the constant "message" -- an assumption that would silently discard data the day someone picks a longer one. Measured what the raw LZ4CodecV2 decoder actually does per declared length, which showed the pre-check was doing much less than its javadoc implied (9 GB heap): negative -> NegativeArraySizeException (Exception, already caught) 7 (legitimate) -> IOException (Exception, already caught) 1,886,151,017 -> IOException (allocated 1.8 GiB, then failed) Integer.MAX_VALUE -> OutOfMemoryError (Error, escaped) Only the last row needed guarding, and which row a length lands in moves with -Xmx: 1.8 GiB is an IOException on a 9 GB heap and an OutOfMemoryError on a 1 GB container. So the fix is to widen the catch to Exception | OutOfMemoryError, which makes the behaviour heap-independent without inventing a limit. No other Error is absorbed -- StackOverflowError and an OOM from a legitimate multi-hundred-MB payload document still propagate. Two residual risks, documented in the javadoc rather than papered over: an allocation large enough to fail can starve a different thread, which then throws where nothing catches it; and a length the heap can satisfy is still allocated in full before it fails. Both are properly fixed by the CompositeCodec argument-order change, which takes LZ4 off the field-name path and dissolves the hazard instead of bounding it. Tests: the four ceiling tests collapse into one parameterized case asserting the sentinel for negative, MIN_VALUE, 1.8 GiB and MAX_VALUE lengths, plus a direct OOM-absorption test and a StackOverflowError propagation test for the boundary. Mutation check on the OOM arm does not fail an assertion -- it kills the forked JVM (Tests run: 0, BUILD FAILURE), which is a stronger signal but easy to misread, so noting it here. 55 green across the four affected suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4f4a679 commit d7dc961

2 files changed

Lines changed: 76 additions & 178 deletions

File tree

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

Lines changed: 37 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
@AllArgsConstructor
2626
@Getter
2727
public enum RedisStreamCodec {
28-
JAVA(Constants.JAVA, Suppliers.memoize(() -> faultTolerant(new CompositeCodec(
29-
lengthBoundedLz4Codec(new LZ4CodecV2()), new JsonJacksonCodec(buildStreamMapper()))))),
28+
JAVA(Constants.JAVA, Suppliers.memoize(() -> faultTolerant(new CompositeCodec(new LZ4CodecV2(),
29+
new JsonJacksonCodec(buildStreamMapper()))))),
3030
JSON(Constants.JSON, () -> StringCodec.INSTANCE);
3131

3232
/**
@@ -67,124 +67,6 @@ static Codec faultTolerant(Codec delegate) {
6767
return new FaultTolerantCodec(delegate);
6868
}
6969

70-
/**
71-
* Ceiling on a declared LZ4-decompressed length, checked before {@link LZ4CodecV2} allocates for it.
72-
* <p>
73-
* {@code LZ4CodecV2$1.decode} reads a 4-byte declared length straight off the wire and immediately
74-
* allocates a raw heap {@code byte[]} of exactly that size, with no bound of its own. Verified
75-
* against the Redisson 4.7.0 bytecode actually on the classpath ({@code redisson.version} in
76-
* {@code pom.xml}):
77-
*
78-
* <pre>
79-
* 1: invokevirtual ByteBuf.readInt:()I // declared length, straight off the wire
80-
* 6: newarray byte // byte[declaredLength], UNBOUNDED
81-
* 24: new BlockLZ4CompressorInputStream // decompressor built AFTER the allocation
82-
* 44: invokevirtual DataInputStream.readFully // frame parsing only starts here
83-
* </pre>
84-
*
85-
* The ordering is the whole problem: nothing validates the frame before the array exists, so a
86-
* corrupted or truncated frame can claim any 31-bit length. Because it is a heap array the failure
87-
* is {@code OutOfMemoryError: Java heap space} specifically -- not a Netty direct-memory error,
88-
* which this path cannot produce. Either way it is an {@link Error}, which
89-
* {@link FaultTolerantCodec} deliberately does not absorb (an OOM from a legitimate
90-
* multi-hundred-MB document is meant to propagate), so left unguarded this reproduces the
91-
* OPIK-8164 wedge through a corrupted length header instead of an oversized string: the throw
92-
* lands before any {@code StreamMessageId} exists, so the entry can never be acked, retried or
93-
* removed.
94-
* <p>
95-
* Note the sibling {@code LZ4Codec$1} (V1, unused here) allocates via
96-
* {@code ByteBufAllocator.DEFAULT.buffer(int)} instead. Same unbounded-length flaw, different
97-
* failure mode -- worth not confusing the two when re-checking this against a future version.
98-
* <p>
99-
* On this codec {@code LZ4CodecV2} sits on the map-KEY (field-name) path -- see the
100-
* {@link CompositeCodec} arguments above -- and every stream field name in this codebase is the
101-
* seven-character constant {@code "message"} (e.g. {@code OnlineScoringConfig.PAYLOAD_FIELD}). 4 KB
102-
* is generous by three orders of magnitude, not a tight fit to today's constant: rejecting above it
103-
* costs nothing, because no legitimate field name will ever approach it. The bound is applied to
104-
* both decoder accessors rather than the field-name one alone, hence the codec-scoped name.
105-
*/
106-
@VisibleForTesting
107-
static final int MAX_LZ4_DECODED_LENGTH = 4096;
108-
109-
private static Codec lengthBoundedLz4Codec(LZ4CodecV2 lz4) {
110-
return new LengthBoundedLz4Codec(lz4);
111-
}
112-
113-
/**
114-
* Rejects an implausible declared length before {@code LZ4CodecV2} ever allocates for it. See
115-
* {@link #MAX_LZ4_DECODED_LENGTH} for the vulnerability and why the bound is safe.
116-
* <p>
117-
* Deliberately narrow rather than folded into {@link FaultTolerantCodec}'s generic wrapper: this
118-
* class assumes a specific wire layout (a 4-byte declared-length header), which only holds for
119-
* {@link LZ4CodecV2}. {@link FaultTolerantCodec} stays codec-agnostic on purpose -- its own javadoc
120-
* says as much -- so a peek this specific does not belong inside it.
121-
* <p>
122-
* Both {@code getMapKeyDecoder} and {@code getValueDecoder} are bounded, not just the one this
123-
* codec's wiring exercises ({@code getMapKeyDecoder}, which {@code BaseCodec} falls through to
124-
* {@code getValueDecoder} for): {@link LZ4CodecV2} could be composed as a value codec elsewhere,
125-
* and the same allocation is reachable through either accessor.
126-
*/
127-
@RequiredArgsConstructor
128-
private static final class LengthBoundedLz4Codec implements Codec {
129-
130-
private final LZ4CodecV2 delegate;
131-
132-
private static Decoder<Object> bounded(Decoder<Object> decoder) {
133-
return (buf, state) -> {
134-
if (buf.readableBytes() >= Integer.BYTES) {
135-
int declaredLength = buf.getInt(buf.readerIndex());
136-
if (declaredLength < 0 || declaredLength > MAX_LZ4_DECODED_LENGTH) {
137-
int encodedBytes = buf.readableBytes();
138-
buf.skipBytes(encodedBytes);
139-
return UndecodableStreamMessage.builder()
140-
.encodedBytes(encodedBytes)
141-
.cause(new IllegalStateException(
142-
"LZ4 frame declares a %d-byte decompressed length, over the %d-byte "
143-
+ "sanity ceiling for an LZ4 frame"
144-
.formatted(declaredLength, MAX_LZ4_DECODED_LENGTH)))
145-
.build();
146-
}
147-
}
148-
return decoder.decode(buf, state);
149-
};
150-
}
151-
152-
@Override
153-
public Decoder<Object> getMapValueDecoder() {
154-
return delegate.getMapValueDecoder();
155-
}
156-
157-
@Override
158-
public Encoder getMapValueEncoder() {
159-
return delegate.getMapValueEncoder();
160-
}
161-
162-
@Override
163-
public Decoder<Object> getMapKeyDecoder() {
164-
return bounded(delegate.getMapKeyDecoder());
165-
}
166-
167-
@Override
168-
public Encoder getMapKeyEncoder() {
169-
return delegate.getMapKeyEncoder();
170-
}
171-
172-
@Override
173-
public Decoder<Object> getValueDecoder() {
174-
return bounded(delegate.getValueDecoder());
175-
}
176-
177-
@Override
178-
public Encoder getValueEncoder() {
179-
return delegate.getValueEncoder();
180-
}
181-
182-
@Override
183-
public ClassLoader getClassLoader() {
184-
return delegate.getClassLoader();
185-
}
186-
}
187-
18870
/**
18971
* Delegates encoding untouched; every decoder is wrapped so no decode path can throw.
19072
* <p>
@@ -208,10 +90,40 @@ public ClassLoader getClassLoader() {
20890
* general wrapper, not a JAVA-specific one, and a three-arg rewiring would otherwise silently
20991
* reopen the hole.</li>
21092
* </ul>
211-
* The catch is {@link Exception}, not {@link Throwable}: an {@link Error} — an
212-
* {@link OutOfMemoryError} while Jackson materializes a multi-megabyte String is the plausible one
213-
* here — still propagates, because a JVM in that state should not have its failure recorded as a
214-
* routine per-message drop.
93+
* The catch covers {@link Exception} plus {@link OutOfMemoryError}, and nothing else. The OOM arm is
94+
* not defensive garnish — it closes a real hole. {@code LZ4CodecV2$1.decode} reads a 4-byte declared
95+
* decompressed length off the wire and immediately does {@code newarray byte} of exactly that size,
96+
* building the decompressor and validating the frame only afterwards (Redisson 4.7.0 bytecode:
97+
* {@code readInt} at 1, {@code newarray} at 6, {@code BlockLZ4CompressorInputStream} at 24,
98+
* {@code readFully} at 44). A corrupted field-name frame can therefore claim any 31-bit length. What
99+
* that produces depends on the heap, which is why it cannot be left to {@link Exception} alone:
100+
*
101+
* <table border="1">
102+
* <caption>Raw {@code LZ4CodecV2} outcome by declared length, measured on a 9 GB heap</caption>
103+
* <tr><th>Declared length</th><th>Outcome</th></tr>
104+
* <tr><td>negative</td><td>{@code NegativeArraySizeException} — an Exception</td></tr>
105+
* <tr><td>large but allocatable</td><td>allocates, then {@code IOException} — an Exception</td></tr>
106+
* <tr><td>beyond the heap</td><td>{@link OutOfMemoryError} — an Error</td></tr>
107+
* </table>
108+
*
109+
* Only the last row needs the OOM arm, and which row a given length lands in moves with
110+
* {@code -Xmx}: 1.8 GiB is an {@code IOException} on a 9 GB heap and an {@link OutOfMemoryError} on a
111+
* 1 GB container. Absorbing it makes the behaviour heap-independent. Without it that row is the
112+
* OPIK-8164 wedge again, reached through a corrupted length header instead of an oversized string.
113+
* <p>
114+
* Two honest limits on this. The failing array is unreferenced the moment we return, so the JVM
115+
* recovers — but an allocation large enough to fail can starve a <em>different</em> thread, which
116+
* then throws where nothing catches it; and a length the heap can satisfy is still allocated in full
117+
* before it fails. Bounding the declared length before the allocation would fix both, and an earlier
118+
* revision of this class did exactly that. It was removed because the ceiling was an undocumented
119+
* number: nothing in Redis or Redisson constrains a stream field name, so any bound rests on the
120+
* local observation that every field name in this codebase is the constant {@code "message"} — an
121+
* assumption that silently discards data the day someone picks a longer one. A pre-check belongs
122+
* with the {@link CompositeCodec} argument-order fix, which removes LZ4 from the field-name path and
123+
* dissolves this hazard rather than bounding it.
124+
* <p>
125+
* No other {@link Error} is absorbed: an {@link OutOfMemoryError} from a legitimate
126+
* multi-hundred-MB payload document, or a {@link StackOverflowError}, still propagates.
215127
*/
216128
@RequiredArgsConstructor
217129
private static final class FaultTolerantCodec implements Codec {
@@ -223,7 +135,7 @@ private static Decoder<Object> tolerant(Decoder<Object> decoder) {
223135
int encodedBytes = buf.readableBytes();
224136
try {
225137
return decoder.decode(buf, state);
226-
} catch (Exception decodeFailure) {
138+
} catch (Exception | OutOfMemoryError decodeFailure) {
227139
// Belt-and-braces: Redisson hands us a bounded readSlice and never inspects its
228140
// reader index afterwards, so this is not required for framing. Drained anyway so
229141
// the wrapper stays correct if it is ever handed an unsliced buffer.

apps/opik-backend/src/test/java/com/comet/opik/infrastructure/redis/FaultTolerantStreamCodecTest.java

Lines changed: 39 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ void encodersAreUntouched() {
107107
@DisplayName("the shipped JAVA codec tolerates an undecodable field name too")
108108
void shippedJavaCodecToleratesUndecodableMapKey() throws IOException {
109109
// The first four bytes are LZ4CodecV2's decompressed-length header, which its decoder reads and
110-
// allocates. Kept explicit and small (well under MAX_LZ4_DECODED_LENGTH, see the boundary
111-
// tests below) so this exercises the OUTER FaultTolerantCodec.tolerant() catching an ordinary
112-
// decompression failure on a legitimately-sized declared length, distinct from
113-
// mapKeyDecoderRejectsImplausibleDeclaredLength below, which never reaches a decoder at all.
110+
// allocates. Kept small and plausible so this exercises an ordinary decompression failure on a
111+
// legitimately-sized declared length -- the Exception arm -- as distinct from
112+
// corruptedDeclaredLengthYieldsSentinel below, which covers the lengths that can reach the OOM
113+
// arm instead.
114114
var notAnLz4Frame = Unpooled.wrappedBuffer(
115115
new byte[]{0, 0, 0, 16, 'n', 'o', 't', '-', 'l', 'z', '4'});
116116

@@ -120,63 +120,48 @@ void shippedJavaCodecToleratesUndecodableMapKey() throws IOException {
120120
}
121121

122122
/**
123-
* The vulnerability this guards: {@code LZ4CodecV2$1.decode} reads its 4-byte declared length and
124-
* immediately allocates a raw heap {@code byte[]} of that size -- {@code readInt()} then
125-
* {@code newarray byte} -- building the decompressor and parsing the frame only afterwards
126-
* (verified against the Redisson 4.7.0 bytecode on the classpath). A corrupted or truncated frame
127-
* can therefore claim an arbitrary 31-bit length; left unguarded the resulting
128-
* {@code OutOfMemoryError: Java heap space} is an {@link Error} the wrapper does not absorb,
129-
* reproducing the OPIK-8164 wedge through a corrupted header instead of an oversized string.
123+
* The reason the catch covers {@link OutOfMemoryError} and not {@link Exception} alone.
130124
* <p>
131-
* 1,886,151,017 is the big-endian reading of {@code "plai"} -- the first four bytes of the
132-
* free-text buffer an earlier revision of this file used, which is how the hazard was found. Both
133-
* cases assert the length is rejected <em>before</em> any allocation is attempted, not merely that
134-
* it happens not to OOM on this runner.
125+
* {@code LZ4CodecV2$1.decode} allocates {@code newarray byte} of the declared length before it
126+
* validates the frame, so a corrupted field-name header can claim any 31-bit length. Measured
127+
* against the raw decoder, what that produces depends on the heap: negative lengths give
128+
* {@code NegativeArraySizeException}, large-but-allocatable lengths allocate and then give
129+
* {@code IOException}, and lengths beyond the heap give {@link OutOfMemoryError}. Only the last is
130+
* an {@link Error}, and which row a length lands in moves with {@code -Xmx} -- 1.8 GiB is an
131+
* {@code IOException} on a 9 GB heap and an {@link OutOfMemoryError} on a 1 GB container.
132+
* <p>
133+
* All three must yield the sentinel, because any of them escaping is the OPIK-8164 wedge reached
134+
* through a corrupted length header. {@code Integer.MAX_VALUE} is the case that actually exercises
135+
* the OOM arm on a large heap; the others exercise the Exception arm and are included so the
136+
* behaviour is pinned as heap-independent rather than accidentally uniform.
135137
*/
136138
@ParameterizedTest
137-
@ValueSource(ints = {1_886_151_017, Integer.MAX_VALUE, -1, Integer.MIN_VALUE})
138-
@DisplayName("an implausible declared length is rejected before LZ4CodecV2 ever allocates")
139-
void mapKeyDecoderRejectsImplausibleDeclaredLength(int declaredLength) throws IOException {
139+
@ValueSource(ints = {-1, Integer.MIN_VALUE, 1_886_151_017, Integer.MAX_VALUE})
140+
@DisplayName("a corrupted LZ4 declared length yields the sentinel however it fails")
141+
void corruptedDeclaredLengthYieldsSentinel(int declaredLength) throws IOException {
140142
var buf = Unpooled.buffer()
141143
.writeInt(declaredLength)
142144
.writeBytes("frame-body".getBytes(StandardCharsets.UTF_8));
143145

144146
var decoded = RedisStreamCodec.JAVA.getCodec().getMapKeyDecoder().decode(buf, null);
145147

146148
assertThat(decoded).isInstanceOf(UndecodableStreamMessage.class);
147-
assertThat(((UndecodableStreamMessage) decoded).cause())
148-
.hasMessageContaining("sanity ceiling");
149149
}
150150

151151
/**
152-
* The boundary: a declared length exactly at the ceiling must NOT be rejected pre-allocation --
153-
* it has to reach the real decoder, whatever happens to it after that. Distinguished from the
154-
* over-ceiling cases by NOT carrying the "sanity ceiling" message.
152+
* The boundary of what is absorbed. {@link OutOfMemoryError} is in; every other {@link Error} is
153+
* not, so a {@link StackOverflowError} -- or an OOM from a legitimate multi-hundred-MB payload
154+
* document rather than a corrupted length -- still propagates.
155155
*/
156156
@Test
157-
@DisplayName("a declared length exactly at the ceiling is not rejected pre-allocation")
158-
void mapKeyDecoderDoesNotRejectDeclaredLengthAtTheCeiling() throws IOException {
159-
var buf = Unpooled.buffer()
160-
.writeInt(RedisStreamCodec.MAX_LZ4_DECODED_LENGTH)
161-
.writeBytes("not a real lz4 frame body".getBytes(StandardCharsets.UTF_8));
162-
163-
var decoded = RedisStreamCodec.JAVA.getCodec().getMapKeyDecoder().decode(buf, null);
164-
165-
assertThat(decoded).isInstanceOf(UndecodableStreamMessage.class);
166-
assertThat(((UndecodableStreamMessage) decoded).cause().getMessage())
167-
.doesNotContain("sanity ceiling");
168-
}
169-
170-
@Test
171-
@DisplayName("a buffer too short to carry a length header is not rejected by this guard")
172-
void mapKeyDecoderSkipsTheBoundCheckOnATooShortBuffer() throws IOException {
173-
var buf = Unpooled.wrappedBuffer(new byte[]{1, 2});
174-
175-
var decoded = RedisStreamCodec.JAVA.getCodec().getMapKeyDecoder().decode(buf, null);
157+
@DisplayName("an Error other than OutOfMemoryError still propagates")
158+
void nonOomErrorStillPropagates() {
159+
Codec throwsError = RedisStreamCodec.faultTolerant(new StubCodec((b, state) -> {
160+
throw new StackOverflowError("simulated");
161+
}));
176162

177-
assertThat(decoded).isInstanceOf(UndecodableStreamMessage.class);
178-
assertThat(((UndecodableStreamMessage) decoded).cause().getMessage())
179-
.doesNotContain("sanity ceiling");
163+
assertThatThrownBy(() -> throwsError.getMapValueDecoder().decode(json("{}"), null))
164+
.isInstanceOf(StackOverflowError.class);
180165
}
181166

182167
/**
@@ -201,19 +186,20 @@ void partiallyConsumedBufferIsDrained() throws IOException {
201186
}
202187

203188
/**
204-
* The boundary of the no-throw contract. An {@link Error} is deliberately not absorbed: an
205-
* OutOfMemoryError while Jackson materializes a multi-megabyte String is the plausible one on this
206-
* path, and a JVM in that state should not have its failure filed as a routine per-message drop.
189+
* The OOM arm, driven directly rather than through LZ4's allocation, so it is pinned independently
190+
* of any heap size. See {@code corruptedDeclaredLengthYieldsSentinel} for why it is absorbed.
207191
*/
208192
@Test
209-
@DisplayName("an Error still propagates -- only Exception is absorbed")
210-
void errorStillPropagates() {
211-
Codec throwsError = RedisStreamCodec.faultTolerant(new StubCodec((b, state) -> {
193+
@DisplayName("an OutOfMemoryError from a decoder is absorbed into the sentinel")
194+
void outOfMemoryErrorIsAbsorbed() throws IOException {
195+
Codec throwsOom = RedisStreamCodec.faultTolerant(new StubCodec((b, state) -> {
212196
throw new OutOfMemoryError("simulated");
213197
}));
214198

215-
assertThatThrownBy(() -> throwsError.getMapValueDecoder().decode(json("{}"), null))
216-
.isInstanceOf(OutOfMemoryError.class);
199+
var decoded = throwsOom.getMapValueDecoder().decode(json("{}"), null);
200+
201+
assertThat(decoded).isInstanceOf(UndecodableStreamMessage.class);
202+
assertThat(((UndecodableStreamMessage) decoded).cause()).isInstanceOf(OutOfMemoryError.class);
217203
}
218204

219205
/** Minimal codec whose every decoder is the supplied one, for driving the wrapper directly. */

0 commit comments

Comments
 (0)