Skip to content

Commit 7cef0b8

Browse files
thiagohoraclaude
andcommitted
docs: both failure modes measured, and whether absorbing an OOM recovers
Prompted by two review questions I could not answer from the code, so I measured instead of reasoning. Both changed what the javadoc claims. 1. Is the OPIK-8164 failure an OOM? NO. Through the shipped JAVA codec's value decoder at the real 20,000,000 limit: 19,999,999 chars -> decodes fine 20,000,001 chars -> StreamConstraintsException, isError=false An ordinary IOException, matching the production stack trace verbatim. The Exception arm has always handled the literal incident; my previous commit framed the OOM arm as if it closed a hole related to it, which was wrong. 2. But both modes are real, on the SAME path. A payload UNDER maxStringLength but larger than the heap OOMs inside Jackson's own String materialization. On a -Xmx64m fork, 12,000,000 chars (well under the 20,000,000 limit) gave cause=java.lang.OutOfMemoryError, isError=true, absorbed into the sentinel. This is the case that makes the OOM arm matter in production, where maxStringLength ships at 100 MB and consumerBatchSize is 10. 3. Does absorbing an OOM recover? For this shape, yes, measured: three consecutive rounds of oversized-then-ordinary on a 64 MB heap each absorbed the OOM and then decoded the ordinary message correctly, free heap stable, no cumulative degradation. The array that failed was never allocated, so the failure consumed nothing. The operational argument settles it: the helm chart ships -XX:+UseG1GC -XX:MaxRAMPercentage=80.0 with NO -XX:+ExitOnOutOfMemoryError, so the process already survives an OOM today. Not absorbing does not buy a clean restart -- it buys a still-running pod with a permanently wedged stream. If the team ever adds ExitOnOutOfMemoryError the JVM exits at throw time and this arm becomes unreachable, which is fine. Documented what it does NOT claim: a JVM under genuine heap exhaustion is not healthy, this arm can absorb an OOM that was a symptom rather than a cause, and the recovery measured is single-threaded. Tests: the over-limit case now asserts the cause is StreamConstraintsException and is NOT an Error, which is the distinction both questions turned on. Also removed a shipped-codec test I had just added -- it needed an assumeTrue on the memoized limit and skipped in the real suite, because RedisStreamCodecTest calls JsonUtils.configure in the same JVM. The small-limit mapper exercises the identical path; the real-limit numbers live in the javadoc as measured evidence instead. 58 green, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d7dc961 commit 7cef0b8

2 files changed

Lines changed: 97 additions & 31 deletions

File tree

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

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -90,40 +90,77 @@ static Codec faultTolerant(Codec delegate) {
9090
* general wrapper, not a JAVA-specific one, and a three-arg rewiring would otherwise silently
9191
* reopen the hole.</li>
9292
* </ul>
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:
93+
* The catch covers {@link Exception} plus {@link OutOfMemoryError}, and nothing else. Both arms are
94+
* load-bearing, for different failures on different paths, and the OOM arm is <em>not</em> only a
95+
* defence against corrupted frames — it also covers the primary payload path. All four rows below
96+
* were measured through {@link #JAVA}'s own decoders, not reasoned about:
10097
*
10198
* <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>
99+
* <caption>Observed failure modes</caption>
100+
* <tr><th>Input</th><th>Path</th><th>Result</th><th>Arm</th></tr>
101+
* <tr><td>String over {@code maxStringLength}</td><td>value</td>
102+
* <td>{@code StreamConstraintsException} — the literal OPIK-8164 failure</td><td>Exception</td></tr>
103+
* <tr><td>String under the limit, over the heap</td><td>value</td>
104+
* <td>{@link OutOfMemoryError} inside Jackson's String materialization</td><td><b>OOM</b></td></tr>
105+
* <tr><td>Negative LZ4 declared length</td><td>field name</td>
106+
* <td>{@code NegativeArraySizeException}</td><td>Exception</td></tr>
107+
* <tr><td>LZ4 declared length beyond the heap</td><td>field name</td>
108+
* <td>{@link OutOfMemoryError} from {@code newarray byte}</td><td><b>OOM</b></td></tr>
107109
* </table>
108110
*
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.
111+
* The second row is the one that makes the OOM arm matter in production rather than in theory.
112+
* {@code jacksonConfig.maxStringLength} ships at 100 MB, so a payload well inside the configured
113+
* limit can still exhaust a container heap while Jackson materializes it — and at
114+
* {@code consumerBatchSize: 10} ten such decodes run concurrently. Verified on a {@code -Xmx64m}
115+
* fork: a 12,000,000-character payload, comfortably under the 20,000,000 default limit, produced
116+
* {@code cause=java.lang.OutOfMemoryError}. Without this arm that Error escapes into
117+
* {@code CommandDecoder} with no {@code StreamMessageId} and wedges the stream — the same outcome
118+
* OPIK-8164 produced, reached by running out of heap rather than by breaching a limit.
113119
* <p>
114-
* Two honest limits on this. The failing array is unreferenced the moment we return, so the JVM
120+
* Rows three and four are the LZ4 field-name path. {@code LZ4CodecV2$1.decode} reads a 4-byte
121+
* declared decompressed length and immediately does {@code newarray byte} of that size, building the
122+
* decompressor and validating the frame only afterwards (Redisson 4.7.0: {@code readInt} at 1,
123+
* {@code newarray} at 6, {@code BlockLZ4CompressorInputStream} at 24, {@code readFully} at 44). Which
124+
* of the two arms catches a given length depends on {@code -Xmx}: 1.8 GiB is an {@code IOException}
125+
* on a 9 GB heap and an {@link OutOfMemoryError} on a 1 GB container. Absorbing both makes the
126+
* behaviour heap-independent.
127+
* <p>
128+
* Two honest limits. The failing allocation is unreferenced the moment we return, so the JVM
115129
* 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.
130+
* then throws where nothing catches it; and a size the heap can satisfy is still allocated in full
131+
* before it fails. Bounding the LZ4 declared length pre-allocation would narrow row four, and an
132+
* earlier revision did that; it was removed because the ceiling was an undocumented number resting
133+
* on the local observation that every field name here is the constant {@code "message"}. Row four
134+
* is better fixed by the {@link CompositeCodec} argument-order change, which takes LZ4 off the
135+
* field-name path entirely. Row two has no such escape: materializing a large document is the
136+
* legitimate work, so absorbing the OOM is the only option short of a size guard at publish time.
137+
* <p>
138+
* <h4>Is absorbing an {@link OutOfMemoryError} recoverable?</h4>
139+
*
140+
* For this failure shape, yes, and it was measured rather than assumed. On a {@code -Xmx64m} fork,
141+
* three consecutive rounds of "oversized payload, then an ordinary message" each absorbed the OOM
142+
* and then decoded the ordinary message correctly, with free heap stable across rounds — no
143+
* cumulative degradation. That holds because the array which failed to allocate was never
144+
* allocated, so the failure consumed nothing, and the oversized buffer is unreferenced the moment
145+
* this method returns.
146+
* <p>
147+
* The operational comparison is the part that settles it. The shipped JVM options
148+
* ({@code -XX:+UseG1GC -XX:MaxRAMPercentage=80.0} in the helm chart) do <em>not</em> include
149+
* {@code -XX:+ExitOnOutOfMemoryError}, so the process already survives an OOM today. Not absorbing
150+
* therefore does not buy a clean restart — it buys a still-running pod with a permanently wedged
151+
* stream, which is strictly worse than dropping one message. Should the team ever add
152+
* {@code ExitOnOutOfMemoryError}, the JVM exits at throw time and this arm simply becomes
153+
* unreachable; nothing here depends on it staying absent.
154+
* <p>
155+
* What this does <em>not</em> claim: that a JVM under genuine heap exhaustion is healthy. If the
156+
* heap is being exhausted by other work, this arm will absorb an OOM that was a symptom rather than
157+
* a cause, and keep consuming — masking it. The recovery measured above is single-threaded and
158+
* proves the decode path, not a loaded service; an allocation large enough to fail can still starve
159+
* a different thread, which throws where nothing catches it. The counter to that is a size guard at
160+
* publish time, which is what {@code onlineScoring.dropOversizedPayloads} does, not anything this
161+
* codec can do on read.
124162
* <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.
163+
* No other {@link Error} is absorbed — a {@link StackOverflowError} still propagates.
127164
*/
128165
@RequiredArgsConstructor
129166
private static final class FaultTolerantCodec implements Codec {

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.comet.opik.infrastructure.redis;
22

33
import com.fasterxml.jackson.core.StreamReadConstraints;
4+
import com.fasterxml.jackson.core.exc.StreamConstraintsException;
45
import com.fasterxml.jackson.databind.ObjectMapper;
56
import io.netty.buffer.ByteBuf;
67
import io.netty.buffer.Unpooled;
@@ -32,7 +33,18 @@ class FaultTolerantStreamCodecTest {
3233

3334
private static final int SMALL_STRING_LIMIT = 64;
3435

35-
/** A mapper whose string limit is small enough to breach without allocating megabytes. */
36+
/**
37+
* A mapper whose string limit is small enough to breach without allocating megabytes.
38+
* <p>
39+
* This is the same code path as the shipped codec's value decoder --
40+
* {@code faultTolerant(JsonJacksonCodec).getMapValueDecoder()} either way, since
41+
* {@code CompositeCodec} routes map values to {@code JsonJacksonCodec} here -- with only the
42+
* mapper's limit differing. Deliberately not driven through {@code RedisStreamCodec.JAVA} at its
43+
* real limit: the enum memoizes a copy of {@code JsonUtils}' mapper at build time, and
44+
* {@code RedisStreamCodecTest} calls {@code JsonUtils.configure} in the same JVM, so the effective
45+
* limit there is order-dependent and a test asserting it either skips or flakes. The real-limit
46+
* behaviour is recorded as measured evidence in {@code FaultTolerantCodec}'s javadoc instead.
47+
*/
3648
private static Codec codecWithSmallStringLimit() {
3749
var mapper = new ObjectMapper();
3850
mapper.getFactory().setStreamReadConstraints(
@@ -56,7 +68,12 @@ void oversizedPayloadYieldsSentinel() throws IOException {
5668
assertThat(decoded).isInstanceOf(UndecodableStreamMessage.class);
5769
var undecodable = (UndecodableStreamMessage) decoded;
5870
assertThat(undecodable.encodedBytes()).isEqualTo(encodedBytes);
71+
// The type matters, not just the text: this is the OPIK-8164 failure, and it is an ordinary
72+
// IOException, NOT an Error. Whether the Exception arm alone suffices for the incident turns on
73+
// exactly this -- see heapExhaustionDuringMaterializationIsAbsorbed for the case where it does not.
5974
assertThat(undecodable.cause())
75+
.isInstanceOf(StreamConstraintsException.class)
76+
.isNotInstanceOf(Error.class)
6077
.hasMessageContaining("maximum allowed");
6178
// The size is reported from the buffer, so it survives the failed decode consuming it.
6279
assertThat(buf.isReadable()).isFalse();
@@ -186,12 +203,24 @@ void partiallyConsumedBufferIsDrained() throws IOException {
186203
}
187204

188205
/**
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.
206+
* Why the {@link OutOfMemoryError} arm is not only about corrupted LZ4 lengths: it also covers the
207+
* PRIMARY payload path. A payload <em>under</em> {@code maxStringLength} but larger than the heap
208+
* OOMs inside Jackson's own String materialization, before any constraint is breached.
209+
* <p>
210+
* Measured, not reasoned: on a fork with {@code -Xmx64m}, a 12,000,000-character payload (well
211+
* under the 20,000,000 limit) through {@code RedisStreamCodec.JAVA}'s value decoder yields
212+
* {@code SENTINEL, cause=java.lang.OutOfMemoryError, isError=true}. This matters in production,
213+
* where {@code maxStringLength} is 100 MB and {@code consumerBatchSize} is 10 -- ten concurrent
214+
* materializations of a large trace is exactly this case, and without this arm the OOM escapes into
215+
* {@code CommandDecoder} and wedges the stream.
216+
* <p>
217+
* Driven here through a stub rather than a real allocation, because the surefire JVM's heap is far
218+
* too large to provoke it and forcing a small {@code -Xmx} for one test would slow every other one.
219+
* The real-heap reproduction above is the evidence; this pins the arm.
191220
*/
192221
@Test
193222
@DisplayName("an OutOfMemoryError from a decoder is absorbed into the sentinel")
194-
void outOfMemoryErrorIsAbsorbed() throws IOException {
223+
void heapExhaustionDuringMaterializationIsAbsorbed() throws IOException {
195224
Codec throwsOom = RedisStreamCodec.faultTolerant(new StubCodec((b, state) -> {
196225
throw new OutOfMemoryError("simulated");
197226
}));

0 commit comments

Comments
 (0)