Skip to content

fix(api): reduce refly-api RSS from image path and stream snapshots - #2290

Merged
lefarcen merged 3 commits into
mainfrom
fix/api-rss-image-allocator-and-snapshot-debounce
Jul 29, 2026
Merged

fix(api): reduce refly-api RSS from image path and stream snapshots#2290
lefarcen merged 3 commits into
mainfrom
fix/api-rss-image-allocator-and-snapshot-debounce

Conversation

@mrcfps

@mrcfps mrcfps commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Cap concurrent Sharp/base64 image work per pod (IMAGE_PROCESS_CONCURRENCY default 2, clamp 1–4), set sharp.concurrency(1), and remove a redundant Buffer.from copy on the vision base64 path — main RSS ratchet driver is glibc arena fragmentation under concurrent libvips + multi-buffer Base64, not a JS retained-object leak.
  • Set MALLOC_ARENA_MAX=2 in the API Dockerfile and helm values (Sharp/glibc allocator guidance).
  • Debounce ResultAggregator Redis step snapshots (200ms coalesce + single-flight, terminal clearCache) to cut secondary stream allocation churn; unit tests cover debounce and clear races.

Deploy notes

  • Prefer a one-pod canary (or pause rolling after the first pod) before fleet rollout.
  • Monitor RSS peak/baseline after image batches, OOM/restarts, image-path p95/p99 latency, CPU, error rate.
  • Rollback: remove MALLOC_ARENA_MAX or set to 4; optionally tune IMAGE_PROCESS_CONCURRENCY in 1–4.
  • Shipping image-path + allocator first makes RSS attribution clearer; snapshot debounce is secondary.

Test plan

  • pnpm exec jest src/utils/result.spec.ts (apps/api) — 3/3 pass
  • Canary one refly-api pod with new image + MALLOC_ARENA_MAX=2
  • Compare canary vs control: RSS after image/Base64 skill load, residual baseline, OOM
  • Spot-check vision (bedrock/vertex base64) still returns images under multi-file input
  • Spot-check long skill stream still persists final steps to DB; mid-stream Redis lag ≤200ms is expected

Summary by CodeRabbit

  • Performance
    • Reduced memory fragmentation by setting allocator arena limits in the API container.
    • Improved drive base64 image generation stability by capping concurrent conversions.
  • Bug Fixes
    • Improved result step persistence by debouncing rapid updates and coalescing writes to the latest snapshot.
    • Cache clearing now permanently prevents future persistence, and pending updates behave consistently.
    • Updated abort behavior to flush any pending snapshot without scheduling further writes.
  • Tests
    • Added Jest coverage for debounced persistence, in-flight updates, cache clearing, and abort behavior.

Cap concurrent Sharp/base64 work, set MALLOC_ARENA_MAX=2, and debounce
ResultAggregator Redis snapshots to limit glibc arena fragmentation.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5998918d-74fb-4e2e-8e65-455bb0bf0620

📥 Commits

Reviewing files that changed from the base of the PR and between fd10eb2 and c46aa01.

📒 Files selected for processing (1)
  • apps/api/src/modules/drive/drive.service.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/src/modules/drive/drive.service.ts

📝 Walkthrough

Walkthrough

The PR limits Sharp/libvips memory usage and concurrency, and changes ResultAggregator to use debounced, coalesced cache persistence with explicit flush, abort, and cache-clearing behavior.

Changes

Image memory controls

Layer / File(s) Summary
Allocator and image-processing limits
apps/api/Dockerfile, deploy/helm/refly-api/values.yaml, apps/api/src/modules/drive/drive.service.ts
Production configuration sets MALLOC_ARENA_MAX=2; Drive base64 image conversions are limited to 1–4 concurrent tasks, with Sharp configured for single-threaded concurrency.

Result persistence lifecycle

Layer / File(s) Summary
Debounced persistence pipeline
apps/api/src/utils/result.ts
ResultAggregator coalesces delayed writes, flushes pending updates, coordinates in-flight persistence, and prevents writes after clearCache().
Persistence lifecycle tests
apps/api/src/utils/result.spec.ts
Tests cover batched writes, in-flight clear races, abort flushing, and mutations after cache clearing.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ResultAggregator
  participant StepService
  participant Redis
  ResultAggregator->>ResultAggregator: Schedule debounced persistence
  ResultAggregator->>StepService: Persist latest steps
  StepService->>Redis: Write cache entry
  ResultAggregator->>StepService: Clear cache after in-flight write completes
  StepService->>Redis: Delete cache entry
Loading

Suggested reviewers: anthhub, ch1111

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main goal: reducing refly-api RSS from image processing and stream snapshot handling.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/api-rss-image-allocator-and-snapshot-debounce

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying refly-branch-test with  Cloudflare Pages  Cloudflare Pages

Latest commit: c46aa01
Status: ✅  Deploy successful!
Preview URL: https://93b571d0.refly-branch-test.pages.dev
Branch Preview URL: https://fix-api-rss-image-allocator.refly-branch-test.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eea7c9c5e7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/src/utils/result.ts Outdated

@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: 2

🧹 Nitpick comments (3)
apps/api/src/modules/drive/drive.service.ts (1)

69-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Name the concurrency bounds and default.

The minimum, maximum, and fallback values are deployment-policy constants; naming them prevents the clamp/default contract from drifting.

Proposed fix
+const MIN_IMAGE_PROCESS_CONCURRENCY = 1;
+const MAX_IMAGE_PROCESS_CONCURRENCY = 4;
+const DEFAULT_IMAGE_PROCESS_CONCURRENCY = 2;
+
 const rawImageConcurrency = Number.parseInt(process.env.IMAGE_PROCESS_CONCURRENCY ?? '', 10);
 const imageProcessConcurrency = Number.isFinite(rawImageConcurrency)
-  ? Math.min(4, Math.max(1, rawImageConcurrency))
-  : 2;
+  ? Math.min(MAX_IMAGE_PROCESS_CONCURRENCY, Math.max(MIN_IMAGE_PROCESS_CONCURRENCY, rawImageConcurrency))
+  : DEFAULT_IMAGE_PROCESS_CONCURRENCY;

As per coding guidelines, “Avoid magic numbers and strings - use named constants in TypeScript/JavaScript.”

🤖 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 `@apps/api/src/modules/drive/drive.service.ts` around lines 69 - 73, Name the
image processing concurrency policy values as constants for the minimum,
maximum, and fallback/default, then use those constants in the clamp and
fallback within the imageProcessConcurrency initialization. Keep the existing
bounds and default behavior unchanged and apply the change around
rawImageConcurrency and imageProcessLimit.

Source: Coding guidelines

apps/api/src/utils/result.ts (1)

18-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Export PERSIST_DEBOUNCE_MS for test reuse.

The constant isn't exported, so result.spec.ts hardcodes the literal 200 in multiple places to match it. If this value changes, tests will silently desync from the implementation.

As per coding guidelines, "Avoid magic numbers and strings - use named constants in TypeScript/JavaScript."

♻️ Proposed fix
-const PERSIST_DEBOUNCE_MS = 200;
+export const PERSIST_DEBOUNCE_MS = 200;
🤖 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 `@apps/api/src/utils/result.ts` around lines 18 - 19, Export the
PERSIST_DEBOUNCE_MS constant from result.ts so result.spec.ts can import and
reuse it instead of hardcoding 200, keeping the implementation and tests
synchronized.

Source: Coding guidelines

apps/api/src/utils/result.spec.ts (1)

19-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer jest.advanceTimersByTimeAsync() over advanceTimersByTime() + manual Promise.resolve() flushing.

The implementation's debounce chain is several awaits deep (timer → flushPersistStepsrunPersistLooppersistStepsNowsetCache). A fixed count of await Promise.resolve() calls after jest.advanceTimersByTime() is a known source of flaky tests once promise-chain depth changes, since sync advanceTimersByTime doesn't flush microtasks between timer executions. Jest 29 (already a project dependency) provides advanceTimersByTimeAsync() specifically to flush microtasks between timer runs, making these tests more robust against implementation changes to the chain depth.

♻️ Example
-jest.advanceTimersByTime(200);
-await Promise.resolve();
-await Promise.resolve();
+await jest.advanceTimersByTimeAsync(200);

Also applies to: 52-58, 80-99, 104-115, 119-124, 140-142

🤖 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 `@apps/api/src/utils/result.spec.ts` at line 19, Update the timer-based tests
in result.spec.ts to use jest.advanceTimersByTimeAsync() instead of synchronous
timer advancement followed by manual Promise.resolve() flushing. Apply this to
the cases around the existing timer setup and referenced test ranges, removing
fixed microtask flushes while preserving each test’s timing and assertions.
🤖 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 `@apps/api/src/utils/result.ts`:
- Around line 210-215: Update addUsageItem() to honor the aborted lifecycle
established by addSkillEvent() and handleStreamContent(): return immediately
when this.aborted is true, preventing post-abort usage updates from scheduling
persistence. If usage items must still be retained after abort, preserve the
data update but guard schedulePersistSteps() so it cannot rearm a Redis-write
timer.
- Around line 162-166: Update ResultAggregator.abort() to call
flushPersistSteps() after cancelling persistTimer, ensuring mutations queued
during the debounce window are persisted before abort completes.

---

Nitpick comments:
In `@apps/api/src/modules/drive/drive.service.ts`:
- Around line 69-73: Name the image processing concurrency policy values as
constants for the minimum, maximum, and fallback/default, then use those
constants in the clamp and fallback within the imageProcessConcurrency
initialization. Keep the existing bounds and default behavior unchanged and
apply the change around rawImageConcurrency and imageProcessLimit.

In `@apps/api/src/utils/result.spec.ts`:
- Line 19: Update the timer-based tests in result.spec.ts to use
jest.advanceTimersByTimeAsync() instead of synchronous timer advancement
followed by manual Promise.resolve() flushing. Apply this to the cases around
the existing timer setup and referenced test ranges, removing fixed microtask
flushes while preserving each test’s timing and assertions.

In `@apps/api/src/utils/result.ts`:
- Around line 18-19: Export the PERSIST_DEBOUNCE_MS constant from result.ts so
result.spec.ts can import and reuse it instead of hardcoding 200, keeping the
implementation and tests synchronized.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1692d7d7-0b89-4eb1-9c2e-8c2fbb63e852

📥 Commits

Reviewing files that changed from the base of the PR and between 68f1aa6 and eea7c9c.

📒 Files selected for processing (5)
  • apps/api/Dockerfile
  • apps/api/src/modules/drive/drive.service.ts
  • apps/api/src/utils/result.spec.ts
  • apps/api/src/utils/result.ts
  • deploy/helm/refly-api/values.yaml

Comment thread apps/api/src/utils/result.ts Outdated
Comment thread apps/api/src/utils/result.ts
Re-debounce after in-flight writes instead of immediate loops, flush on
abort, guard addUsageItem when aborted, and tighten related tests/constants.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd10eb27c7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/src/modules/drive/drive.service.ts Outdated

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/utils/result.ts (1)

126-157: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Don’t redirty persistDirty inside concurrent forced flushes.

abort() starts flushPersistSteps() fire-and-forget. If a caller immediately awaits getSteps(), both flushers enter the same in-flight write; the first caller’s post-write loop then sees persistDirty set by the second flusher and issues a duplicate Redis write. The forced persistDirty = true is not needed for the abortgetSteps final-persist path because abort() already leaves the write in-flight.

♻️ Proposed fix
     this.clearPersistTimer();
-    // Ensure at least one attempt when explicitly flushed (e.g. getSteps).
-    this.persistDirty = true;
 
     while (this.persistDirty && !this.persistCleared) {

Add a regression test for abort() followed immediately by getSteps() that asserts setCache() is called only once with final content.

🤖 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 `@apps/api/src/utils/result.ts` around lines 126 - 157, Remove the
unconditional persistDirty = true assignment from flushPersistSteps, while
preserving the existing in-flight coordination and drain loop. Add a regression
test covering abort() followed immediately by getSteps(), asserting setCache()
is called exactly once with the final content.
🤖 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.

Outside diff comments:
In `@apps/api/src/utils/result.ts`:
- Around line 126-157: Remove the unconditional persistDirty = true assignment
from flushPersistSteps, while preserving the existing in-flight coordination and
drain loop. Add a regression test covering abort() followed immediately by
getSteps(), asserting setCache() is called exactly once with the final content.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0aff6972-58e1-4b5a-9579-f58bbf40ae38

📥 Commits

Reviewing files that changed from the base of the PR and between eea7c9c and fd10eb2.

📒 Files selected for processing (3)
  • apps/api/src/modules/drive/drive.service.ts
  • apps/api/src/utils/result.spec.ts
  • apps/api/src/utils/result.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/src/modules/drive/drive.service.ts

Read IMAGE_PROCESS_CONCURRENCY on first use / onModuleInit so ConfigModule
.env values are honored instead of import-time defaults.
@lefarcen
lefarcen merged commit 71f8b87 into main Jul 29, 2026
5 checks passed
@mrcfps
mrcfps deleted the fix/api-rss-image-allocator-and-snapshot-debounce branch July 29, 2026 05:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants