Skip to content

fix(worker): use stream.pipeline for image upload streams (#187)#189

Open
bbornino wants to merge 2 commits into
playfulprogramming:mainfrom
bbornino:fix/stream-pipeline-error-handling
Open

fix(worker): use stream.pipeline for image upload streams (#187)#189
bbornino wants to merge 2 commits into
playfulprogramming:mainfrom
bbornino:fix/stream-pipeline-error-handling

Conversation

@bbornino

@bbornino bbornino commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #187. Replaces the manual source.on('error') listener patched onto sync-author's processProfileImg in #186 with pipeline from node:stream/promises — the real fix James pointed at on the issue rather than that stopgap. Applies the identical fix to sync-collection's processImg, which had the same unfixed bug #187 was filed to track.

Also threads the AbortSignal that createProcessor already hands both processors' callbacks through to processProfileImg/processImg and into pipeline's options — previously unused, meaning a cancelled job wouldn't actually stop an in-flight upload.

apps/worker/src/tasks/url-metadata/utils/processImage.ts is intentionally not touched here — it already has its own custom timeout/dump/comparison logic wrapping its stream handling, and deserves a separate, careful look at whether pipeline plus the signal it already accepts could simplify that existing logic, rather than a drop-in copy of this fix.

Implementation notes

A few choices worth flagging explicitly:

  • Exported processProfileImg and processImg (previously module-private). createProcessor owns its own internal AbortController and never exposes it, so there was no way to drive an "already aborted" scenario through the wrapped processor export from a test. Exporting these two functions made them directly testable for the abort-rejection case.
  • Promise.all([pipeline(source, transform, { signal }), s3.upload(...)]) instead of sequential awaits. pipeline() won't resolve until both the writable and readable sides of the transform stream are settled, but nothing reads the readable side until s3.upload() starts consuming it — awaiting pipeline() first would deadlock waiting on a reader that hasn't started yet.
  • Updated the s3.upload mock in both processor.test.ts files to actually drain the stream it's given. It was previously a bare no-op, which was fine for the old fire-and-forget .pipe(), but a real pipeline() now depends on that stream being consumed to resolve — an inert mock would hang the tests indefinitely.

Test plan

  • pnpm test:unit passes (lint, knip, publint, sherif, vitest all green)
  • Existing sync-author/sync-collection processor tests updated for the new pipeline-based implementation
  • New test in each file confirms an already-aborted signal rejects the upload rather than completing it silently

Summary by CodeRabbit

  • Bug Fixes
    • Improved cancellation handling during author and collection image processing.
    • Image uploads now stop promptly when processing is aborted, preventing stalled operations.
    • Added coverage for aborted image-processing requests to improve reliability.

Replaces the manual source.on('error') listener patched onto
processProfileImg in playfulprogramming#186 with stream.pipeline(), which destroys every
stream in the chain on failure rather than just the one hop we patched
manually. Applies the same fix to sync-collection's processImg, which
had the identical unfixed bug playfulprogramming#187 was filed to track.

Also threads the AbortSignal that createProcessor already provides
through to pipeline's options, so a cancelled job actually stops an
in-flight upload instead of leaving it to complete silently.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 202ca676-2b5a-44d7-b589-a4c151066882

📥 Commits

Reviewing files that changed from the base of the PR and between 95e6aff and 933454d.

📒 Files selected for processing (5)
  • apps/worker/src/tasks/sync-author/processor.test.ts
  • apps/worker/src/tasks/sync-author/processor.ts
  • apps/worker/src/tasks/sync-collection/processor.test.ts
  • apps/worker/src/tasks/sync-collection/processor.ts
  • apps/worker/src/utils/uploadProcessedImage.ts
📝 Walkthrough

Walkthrough

Image uploads in author and collection synchronization now use abort-aware streaming pipelines. Job abort signals are propagated to image helpers, which upload transformed streams through S3. Tests cover aborted signals and drain mocked upload streams.

Changes

Image streaming and abort propagation

Layer / File(s) Summary
Abort-aware image pipeline implementation
apps/worker/src/tasks/sync-author/processor.ts, apps/worker/src/tasks/sync-collection/processor.ts
Image helpers accept AbortSignal, run Sharp transforms through promise-based pipelines, and upload transformed streams concurrently.
Signal propagation and abort coverage
apps/worker/src/tasks/sync-author/processor.ts, apps/worker/src/tasks/sync-author/processor.test.ts, apps/worker/src/tasks/sync-collection/processor.ts, apps/worker/src/tasks/sync-collection/processor.test.ts
Author, cover, and social image call sites pass job signals; tests drain mocked uploads and verify rejection for already-aborted signals.

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

Sequence Diagram(s)

sequenceDiagram
  participant SyncProcessor
  participant ImageHelper
  participant Pipeline
  participant S3
  SyncProcessor->>ImageHelper: pass image stream, upload key, and job signal
  ImageHelper->>Pipeline: process stream through Sharp with signal
  ImageHelper->>S3: upload transformed stream
  Pipeline-->>ImageHelper: complete or reject on abort
  S3-->>ImageHelper: complete upload
Loading

Possibly related issues

  • Issue 187 — Replaces manual image stream piping with abort-aware pipelines, addressing the stream-hang behavior described by the issue.
🚥 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 change: switching worker image uploads to stream.pipeline.
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.
✨ 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.

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

🧹 Nitpick comments (1)
apps/worker/src/tasks/sync-author/processor.ts (1)

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

processProfileImg and processImg are copy-paste duplicates. Both functions have identical bodies — only the constant name differs (PROFILE_IMAGE_SIZE_MAX vs IMAGE_SIZE_MAX, both 2048). Extract a shared image-upload helper to a common module so the two implementations don't diverge.

  • apps/worker/src/tasks/sync-author/processor.ts#L23-L42: replace processProfileImg body with a call to the shared helper.
  • apps/worker/src/tasks/sync-collection/processor.ts#L24-L43: replace processImg body with a call to the same shared helper.
🤖 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/worker/src/tasks/sync-author/processor.ts` around lines 23 - 42, Extract
the duplicated upload pipeline from processProfileImg in
apps/worker/src/tasks/sync-author/processor.ts lines 23-42 and processImg in
apps/worker/src/tasks/sync-collection/processor.ts lines 24-43 into a shared
helper in a common module, preserving the existing stream, resize, bucket,
abort-signal, and S3 upload behavior. Replace both function bodies with calls to
that helper, passing the appropriate image-size constant.
🤖 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/worker/src/tasks/sync-author/processor.ts`:
- Around line 39-42: Update the concurrent pipeline/upload handling in
apps/worker/src/tasks/sync-author/processor.ts lines 39-42 and
apps/worker/src/tasks/sync-collection/processor.ts lines 40-43 so an s3.upload
rejection aborts the shared transform and causes pipeline(source, transform,
...) to unwind immediately, using AbortSignal.any(...) or equivalent transform
destruction while preserving normal successful completion.

---

Nitpick comments:
In `@apps/worker/src/tasks/sync-author/processor.ts`:
- Around line 23-42: Extract the duplicated upload pipeline from
processProfileImg in apps/worker/src/tasks/sync-author/processor.ts lines 23-42
and processImg in apps/worker/src/tasks/sync-collection/processor.ts lines 24-43
into a shared helper in a common module, preserving the existing stream, resize,
bucket, abort-signal, and S3 upload behavior. Replace both function bodies with
calls to that helper, passing the appropriate image-size constant.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 18e838e0-0203-4d15-b74f-15ac709f1bc3

📥 Commits

Reviewing files that changed from the base of the PR and between 48c152f and 95e6aff.

📒 Files selected for processing (4)
  • apps/worker/src/tasks/sync-author/processor.test.ts
  • apps/worker/src/tasks/sync-author/processor.ts
  • apps/worker/src/tasks/sync-collection/processor.test.ts
  • apps/worker/src/tasks/sync-collection/processor.ts

Comment thread apps/worker/src/tasks/sync-author/processor.ts Outdated
…ne race

CodeRabbit flagged two issues on playfulprogramming#189: processProfileImg and processImg
were byte-for-byte duplicates aside from their size constant, and
Promise.all([pipeline(...), s3.upload(...)]) let a rejected upload leave
the pipeline running unaware, since @aws-sdk/lib-storage's Upload doesn't
destroy its input stream on its own - a failed upload could leave the
pipeline hanging onto a stream nobody's consuming anymore.

Extracts both into a single uploadProcessedImage helper in
apps/worker/src/utils, parameterized by max size, called directly from
both processors with their own size constant. This stays in apps/worker
rather than packages/* since it's shared within one app, not across app
boundaries.

Fixes the race with AbortSignal.any(), combining the job's signal with a
new internal AbortController that's triggered if the upload rejects
first, so pipeline unwinds immediately in that case too. The success
path and the job's own signal aborting are unchanged.

beforeEach(() => {
// pipeline() won't resolve until the transform's readable side is drained
vi.mocked(s3.upload).mockImplementation(async (_bucket, _key, _tag, file) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could this be moved to test-utils/setup.ts as a default so that it's shared across tests?

Otherwise LGTM!

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.

Stream errors not forwarded through .pipe() in image processing, causing hangs on mid-transfer failures

2 participants