Skip to content

docs: correct the OOM-arm discharge in RedisStreamCodec — no publish-time guard ships - #8145

Closed
thiagohora wants to merge 1 commit into
mainfrom
thiagohora/OPIK-8192-fu-oom-discharge-doc
Closed

docs: correct the OOM-arm discharge in RedisStreamCodec — no publish-time guard ships#8145
thiagohora wants to merge 1 commit into
mainfrom
thiagohora/OPIK-8192-fu-oom-discharge-doc

Conversation

@thiagohora

Copy link
Copy Markdown
Contributor

Details

Follow-up to review feedback on #8089, which merged before the comment was addressed.

RedisStreamCodec's 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:

$ grep -rn "dropOversizedPayloads" . --exclude-dir=.git
apps/.../RedisStreamCodec.java:167:  * publish time, which is what {@code onlineScoring.dropOversizedPayloads} does, ...

It never shipped. #8060 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, that 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.

Change checklist

  • User facing
  • Documentation update

Comment-only. No behaviour change: the OOM-absorbing call itself is unchanged and was not in question — values.yaml ships -XX:+UseG1GC -XX:MaxRAMPercentage=80.0 with no ExitOnOutOfMemoryError anywhere in the repo, so the process already survives an OOM, and not absorbing it would buy a live pod with a wedged stream.

Issues

  • OPIK-8192 (follow-up)

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5 (1M context)
  • Scope: verifying the reviewer's claim, the wording change, and this description.
  • Human verification: the grep result above was run against this branch; mvn -o compile passes. Comment-only change.

Testing

mvn -o compile -DskipTests — BUILD SUCCESS. No test changes: the diff is a javadoc comment.

…time guard ships

Follow-up to review feedback on #8089, which merged before the comment was
addressed.

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>
@thiagohora
thiagohora requested a review from a team as a code owner September 4, 2026 07:40
@github-actions github-actions Bot added java Pull requests that update Java code Backend 🔵 size/XS labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📋 PR Linter Failed

Invalid Title Format. Your PR title must include a ticket/issue number and may optionally include component tags ([FE], [BE], etc.).

  • Internal contributors: Open a JIRA ticket and link to it: [OPIK-xxxx] or [CUST-xxxx] or [DND-xxxx] or [DEV-xxxx] [COMPONENT] Your change
  • External contributors: Open a Github Issue and link to it via its number: [issue-xxxx] [COMPONENT] Your change
  • No ticket: Use [NA] [COMPONENT] Your change (Issues section not required)

Example: [issue-3108] [BE] [FE] Fix authentication bug or [OPIK-1234] Fix bug or [NA] Update README


Missing Section. The description is missing the ## Documentation section.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
☕ spotless — java backend Format Java code 4.19s
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting 1.51s
Total (2 ran) 5.70s
⏭️ 42 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
🌐 eslint — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️

@CometActions

Copy link
Copy Markdown
Collaborator

No test needed here.

Comment-only change: the diff is four lines inside the FaultTolerantCodec Javadoc, correcting the claim that the residual OOM risk is handled by a publish-time onlineScoring.dropOversizedPayloads guard. It isn't — that key doesn't exist anywhere under apps/opik-backend/src, so the correction is right — but no bytecode changes and nothing a user or API consumer can observe differs. Nothing to test.

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

thiagohora added a commit that referenced this pull request Sep 4, 2026
…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>
@thiagohora

Copy link
Copy Markdown
Contributor Author

Closing in favour of carrying this in #8137, which is already open against the same subsystem — folded in there as commit 8a4a8ec, unchanged (same wording suggested in review on #8089).

Keeping it as a standalone PR wasn't worth a second review cycle for a comment-only change.

@thiagohora thiagohora closed this Sep 4, 2026
@thiagohora
thiagohora deleted the thiagohora/OPIK-8192-fu-oom-discharge-doc branch September 4, 2026 07:47
thiagohora added a commit that referenced this pull request Sep 4, 2026
…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>
thiagohora added a commit that referenced this pull request Sep 4, 2026
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>
thiagohora added a commit that referenced this pull request Sep 4, 2026
* [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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend java Pull requests that update Java code 🔵 size/XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants