Skip to content

Fix SSE events map memory leak#6524

Closed
fxiang1 wants to merge 10 commits into
stolostron:mainfrom
fxiang1:fix/sse-events-map-memory-leak
Closed

Fix SSE events map memory leak#6524
fxiang1 wants to merge 10 commits into
stolostron:mainfrom
fxiang1:fix/sse-events-map-memory-leak

Conversation

@fxiang1

@fxiang1 fxiang1 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📝 Summary

Ticket Summary (Title):
Fix SSE memory leaks: events map fragmentation, handleRequest sizeOf allocations, and eventDict dictionary growth

Type of Change:

  • 🐞 Bug Fix

Problem

Three memory issues in the backend SSE layer cause pod memory to rise monotonically and never reclaim:

1. ServerSideEvents.events — V8 hash table fragmentation

this.events was a plain Record<number, ServerSideEvent> with monotonically increasing integer keys. Every resource modification calls pushEvent (incrementing the key by 2) and removeEvent (delete on the old key). V8 uses "dictionary elements" mode for sparse integer-keyed objects, and delete marks slots as holes without shrinking the backing hash table. With thousands of resources under constant updates, the internal hash table grows to accommodate ever-larger keys but never compacts — causing RSS to rise permanently.

Fix: Replace the plain object with a Map<number, ServerSideEvent>. Map properly releases memory when entries are deleted.

2. handleRequest — expensive sizeOf calls per browser connection

Every time a browser connects to the SSE endpoint, handleRequest called sizeOf() twice:

  1. sizeOf(values)JSON.stringify of ALL compressed event Buffers
  2. sizeOf(sending)JSON.stringify of ALL fully-inflated resource objects

For an environment with 10,000 resources, this created ~500 MB of temporary strings per connection — just to compute a compression ratio for a single logger.info line. These large allocations get promoted to V8's old generation before the young-gen scavenger can collect them, and Node.js almost never returns freed old-gen memory to the OS. Each browser connection ratchets RSS up, and it stays there.

Fix: Remove both sizeOf calls and the unused import. The event count is still logged.

3. eventDict compression dictionary — unbounded growth

The shared compression dictionary (eventDict) only grows via add() — there is no eviction or cleanup path. Short non-numeric strings (< 32 chars) were unconditionally indexed on first encounter, permanently occupying dictionary space even for one-off values like changing label or annotation values. With constant label updates producing new unique values, the dictionary grows without bound.

Longer strings (≥ 32 chars) already had a "seen twice" gate via the bigStrings FifoSet — a string must appear at least twice before being promoted to the dictionary. Short strings bypassed this gate entirely.

Fix: Apply the same "seen twice" gate to short strings. Numeric-looking strings (e.g. "42") are still indexed immediately because the decompressor would misinterpret them as dictionary indices otherwise (Number.isInteger(Number("42")) is true). Non-numeric short strings are stored as-is on first encounter and only promoted to the dictionary when seen a second time. This is backward-compatible: existing compressed Buffers still decompress correctly since all previously-indexed entries remain in the dictionary.


Changes

File Change
backend/src/lib/server-side-events.ts events from Record<number, ServerSideEvent>Map<number, ServerSideEvent>; updated set(), delete(), Array.from(.values()) at all internal access sites; removed sizeOf import and both calls in handleRequest
backend/src/lib/compression.ts Short string compression: hoist dictionary lookup before the length check; numeric strings still always indexed; non-numeric short strings routed through bigStrings "seen twice" gate
backend/test/routes/events.test.ts Updated getEvents() consumers: Object.keys().keys()/.size; events[id]events.get(id); for..in + deletefor..of + .delete()
backend/test/routes/aggregators/utils.test.ts Same for..in + deletefor..of + .delete() in beforeEach/afterEach cleanup
backend/test/lib/compression.test.ts Updated test to assert "seen twice" semantics: first deflation does NOT index short strings, second deflation promotes them

✅ Checklist

General

  • Code builds and runs locally without errors
  • No console logs, commented-out code, or unnecessary files
  • All commits are meaningful and well-labeled

If Bugfix

  • Root cause and fix summary are documented in the ticket (for future reference / errata)
  • Fix tested thoroughly and resolves the issue

🗒️ Notes for Reviewers

  • The sizeOf function itself (in aggregators/utils.ts) is NOT removed — it's still used by other callers. Only the import and usage from server-side-events.ts is removed.
  • The compression field is dropped from the log line. If the compression ratio is needed, it could be computed cheaply by summing Buffer byte lengths instead of doing full JSON serialization.
  • The dictionary fix is backward-compatible: old compressed Buffers decompress correctly because existing dictionary entries are never removed. New compressed Buffers use either dictionary indices (for repeated strings) or literal strings (for first-encounter non-numeric values).
  • All 336 backend tests pass with no regressions.

Summary by CodeRabbit

  • Bug Fixes
    • Improved cleanup of stale cached Kubernetes resources with safer, more efficient reconciliation.
    • Prevented race-condition issues that could remove cache entries while they were being updated.
    • Enhanced server-sent events lifecycle handling for more consistent streaming behavior.
    • Refined compression dictionary behavior for certain short strings to improve stability.
  • Tests
    • Updated event and cache tests to match the new server-sent event management behavior.
    • Adjusted compression tests to reflect the updated short-string dictionary rules.

fxiang1 and others added 7 commits July 18, 2026 10:18
The cleanup loop that removes stale resources after a re-LIST was not
using batchPromiseAll, so it blocked the event loop for the entire
duration—preventing liveness probe responses. Additionally, the
items.find() inside the loop was O(n²). With 22K+ Group resources
this caused probe timeouts and CrashLoopBackOff.

- Convert the for...in cleanup loop to use batchPromiseAll, yielding
  the event loop every 100 entries via setImmediate
- Replace O(n²) items.find() with a Set<string> for O(1) UID lookups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>
Cache entries can be deleted by a concurrent watch event during the
setImmediate yield between batches. Skip UIDs whose cache entry was
removed to prevent a TypeError on undefined.compressed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>
@openshift-ci

openshift-ci Bot commented Jul 20, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: fxiang1

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@fxiang1, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 31 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d410e1df-e1df-4113-a962-6021be2b0852

📥 Commits

Reviewing files that changed from the base of the PR and between 7a70047 and c8ddfb5.

📒 Files selected for processing (2)
  • backend/src/lib/compression.ts
  • backend/test/lib/compression.test.ts
📝 Walkthrough

Walkthrough

ServerSideEvents now stores templates in a Map and removes compression-size logging. Kubernetes cache reconciliation batches stale-resource deletion with cache identity guards. Compression dictionary indexing now promotes repeated short strings, with tests updated for all behavior changes.

Changes

Server-side event storage

Layer / File(s) Summary
Migrate event templates to Map APIs
backend/src/lib/server-side-events.ts, backend/test/routes/aggregators/utils.test.ts, backend/test/routes/events.test.ts
Event initialization, reset, insertion, removal, retrieval, cleanup, lookup, and count assertions now use Map APIs. Compression-size calculation and percentage logging were removed from event-stream handling.

Kubernetes cache reconciliation

Layer / File(s) Summary
Batch stale-resource deletion with identity guards
backend/src/routes/events.ts, backend/test/routes/events.test.ts
Cache reconciliation computes current UIDs, batches stale deletions, and passes expected cache entries through deletion checks before and after asynchronous event processing.

Compression dictionary promotion

Layer / File(s) Summary
Delay indexing of one-off short strings
backend/src/lib/compression.ts, backend/test/lib/compression.test.ts
Timestamps remain unindexed, integer-like short strings are indexed immediately, and other short strings enter the dictionary after a second occurrence.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant cacheResource
  participant deleteResource
  participant ResourceCache
  participant ServerSideEvents
  cacheResource->>deleteResource: stale resource and expected cache entry
  deleteResource->>ResourceCache: verify expected cache entry
  deleteResource->>ServerSideEvents: broadcast DELETED event
  deleteResource->>ResourceCache: verify expected cache entry again
  deleteResource->>ResourceCache: delete cache entry
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: fixing SSE event map memory leakage.
Description check ✅ Passed The description follows the template and covers summary, type, and checklist, but the Ticket Link field is missing.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

fxiang1 added 2 commits July 20, 2026 13:09
Each browser connection triggered two JSON.stringify calls over ALL
cached events (compressed buffers + fully inflated resources) just to
compute a compression ratio for a log message. For 10,000 resources
this created ~500 MB of temporary strings that V8 promoted to old
generation, causing RSS to ratchet up with each connection and never
come back down.

Remove both sizeOf calls and the unused import. The event count is
still logged.

Signed-off-by: fxiang1 <fxiang@redhat.com>
Signed-off-by: fxiang1 <fxiang@redhat.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/lib/compression.ts`:
- Around line 206-226: Move the bigStrings “seen twice” promotion logic inside
the resource.length < 32 && !resource.endsWith('=') guard, after the timestamp
and numeric-string handling. Return the raw resource immediately for its first
short-string occurrence, and ensure long or “=”-terminated strings bypass
bigStrings and dictionary.add entirely.

In `@backend/test/lib/compression.test.ts`:
- Line 109: Update the test case “indexes non-timestamp short strings only after
they are seen twice” to avoid shared compression module state by using unique
input strings that cannot have been cached by other tests; keep the assertions
aligned with those renamed values.
- Around line 129-135: Extend the compression test around deflateResource to
include a string of at least 32 characters in resource. Assert that dict.arr
does not contain it after the first compression and remains absent after
repeated compressions, while preserving the existing short-string promotion
assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 9d3acdfd-bd72-4403-b5e7-ae7c1984ab93

📥 Commits

Reviewing files that changed from the base of the PR and between 10b6075 and 7a70047.

📒 Files selected for processing (3)
  • backend/src/lib/compression.ts
  • backend/src/lib/server-side-events.ts
  • backend/test/lib/compression.test.ts
💤 Files with no reviewable changes (1)
  • backend/src/lib/server-side-events.ts

Comment thread backend/src/lib/compression.ts
})

it('still adds non-timestamp short strings to the dictionary', async () => {
it('indexes non-timestamp short strings only after they are seen twice', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ensure test isolation against shared module state.

The bigStrings cache is a module-level global in compression.ts and is not cleared between tests. If other tests in this suite happen to compress the strings 'Healthy' or 'Synced' before this test runs, those strings will already be in bigStrings. This would cause them to be incorrectly promoted to the dictionary on the first deflateResource call, making this test fail unpredictably.

As per coding guidelines, tests must properly isolate dependencies. Ensure these test strings are unique (e.g., 'Healthy-123', 'Synced-123'), or provide a test-only export in compression.ts to reset bigStrings in a beforeEach hook.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/test/lib/compression.test.ts` at line 109, Update the test case
“indexes non-timestamp short strings only after they are seen twice” to avoid
shared compression module state by using unique input strings that cannot have
been cached by other tests; keep the assertions aligned with those renamed
values.

Source: Coding guidelines

Comment on lines +129 to +135
expect(dict.arr).not.toContain('Healthy')
expect(dict.arr).not.toContain('Synced')

expect(dictEntries).toContain('Healthy')
expect(dictEntries).toContain('Synced')
// Second encounter promotes them to the dictionary
await deflateResource(resource, dict)
expect(dict.arr).toContain('Healthy')
expect(dict.arr).toContain('Synced')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add test coverage to verify long strings are not indexed.

The test name claims the code indexes "short strings" only after they are seen twice, but there is no assertion verifying the behavior for long strings (>= 32 characters). Because of the logic error in compression.ts, long strings are currently also being inadvertently indexed.

As per path instructions, do not infer behavior from test names or comments alone; confirm it from the actual implementation. Additionally, as per coding guidelines, tests should meaningfully cover behavior. Please add an assertion or a separate test case to confirm that strings >= 32 characters are never added to the dictionary, even after being compressed multiple times. This will catch the source bug and prevent regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/test/lib/compression.test.ts` around lines 129 - 135, Extend the
compression test around deflateResource to include a string of at least 32
characters in resource. Assert that dict.arr does not contain it after the first
compression and remains absent after repeated compressions, while preserving the
existing short-string promotion assertions.

Sources: Coding guidelines, Path instructions

Short non-numeric strings were unconditionally indexed on first
encounter, permanently occupying dictionary space even for one-off
values like changing label or annotation values.

Apply the same 'seen twice' gate (via bigStrings FifoSet) that long
strings already use. Numeric-looking strings are still indexed
immediately because the decompressor would misinterpret them as
dictionary indices otherwise. Non-numeric short strings are stored
as-is on first encounter and only promoted to the dictionary when
seen a second time.

Signed-off-by: fxiang1 <fxiang@redhat.com>
@fxiang1
fxiang1 force-pushed the fix/sse-events-map-memory-leak branch from 7a70047 to c8ddfb5 Compare July 20, 2026 19:09
@sonarqubecloud

Copy link
Copy Markdown

@fxiang1 fxiang1 closed this Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant