Skip to content

fix(subscriber): await async calls in MediaSubscriber lifecycle hooks#2745

Open
dougrathbone wants to merge 3 commits intoseerr-team:developfrom
dougrathbone:dougrathbone/fix/media-subscriber-await
Open

fix(subscriber): await async calls in MediaSubscriber lifecycle hooks#2745
dougrathbone wants to merge 3 commits intoseerr-team:developfrom
dougrathbone:dougrathbone/fix/media-subscriber-await

Conversation

@dougrathbone
Copy link
Copy Markdown
Contributor

@dougrathbone dougrathbone commented Mar 22, 2026

Description

MediaSubscriber.beforeUpdate called updateChildRequestStatus without await, and afterUpdate called updateRelatedMediaRequest without await. This created a race condition where the subscriber lifecycle could return before the related MediaRequest status changes were committed to the database -- leaving requests stuck in PENDING or APPROVED instead of being promoted to COMPLETED when media became available.

A second, related issue: both helpers resolved their repositories via the global getRepository() from @server/datasource rather than the transaction-scoped event.manager. This meant their writes executed outside the event's unit of work. If the outer Media save failed after those writes committed, MediaRequest and SeasonRequest rows could diverge from their parent media state.

Fix:

  • Added await to both updateChildRequestStatus and updateRelatedMediaRequest call sites in beforeUpdate and afterUpdate
  • Changed both helper signatures to accept EntityManager as their first parameter, passing event.manager from the subscriber
  • Removed the now-unused getRepository import from @server/datasource
  • Renamed the event parameter to media in updateChildRequestStatus for clarity
  • Batch-save changed MediaRequest rows in updateChildRequestStatus instead of one save per loop iteration

AI Disclosure: I used Claude Code to help write the tests and validate the approach. I reviewed and understood all generated code before submitting.

How Has This Been Tested?

Integration tests at server/subscriber/MediaSubscriber.test.ts using the real SQLite test database via setupTestDb(), covering both standard and 4K request paths.

Tests:

  • PENDING request is promoted to COMPLETED when media transitions PENDING -> AVAILABLE
  • PENDING 4K request is promoted to COMPLETED when media 4K status transitions PENDING -> AVAILABLE
  • APPROVED request is marked COMPLETED when media transitions PROCESSING -> AVAILABLE
  • APPROVED 4K request is marked COMPLETED when media 4K status transitions PROCESSING -> AVAILABLE
  • FAILED request is marked COMPLETED when media transitions PROCESSING -> AVAILABLE

Screenshots / Logs (if applicable)

N/A -- backend-only change with no UI impact.

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly. (no documentation changes required for this fix)
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract (no new UI strings added)
  • Database migration (if required) (no schema changes)

Summary by CodeRabbit

Release Notes

  • Tests

    • Added comprehensive test coverage for media request status updates across availability transitions.
  • Refactor

    • Improved internal database transaction handling for media status updates.

updateChildRequestStatus (called in beforeUpdate) and
updateRelatedMediaRequest (called in afterUpdate) are both async but were
invoked without await, making them fire-and-forget.

In beforeUpdate, this means PENDING requests are not auto-approved before
the media save completes. The afterUpdate hook then runs before those
request status changes are persisted, so they are never seen as APPROVED
and never promoted to COMPLETED.

In afterUpdate, APPROVED requests may not be marked COMPLETED before the
response is returned to the caller.

Adds integration tests that save a media entity with a status transition
and verify the expected final request status, covering both subscriber
hooks.
@dougrathbone dougrathbone requested a review from a team as a code owner March 22, 2026 06:13
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 22, 2026

📝 Walkthrough

Walkthrough

Replaces global repository access with the transaction-scoped EntityManager inside MediaSubscriber and makes subscriber lifecycle helpers awaited; adds integration tests validating MediaRequest status transitions for standard and 4K media status changes.

Changes

Cohort / File(s) Summary
Tests — MediaSubscriber
server/subscriber/MediaSubscriber.test.ts
New integration tests that set up a test DB, create Media/MediaRequest fixtures, exercise various media status and 4K status transitions, and assert MediaRequest.status becomes COMPLETED.
Implementation — MediaSubscriber
server/subscriber/MediaSubscriber.ts
Switched from global getRepository to manager.getRepository(...) in helper methods; changed signatures to accept the event EntityManager; beforeUpdate/afterUpdate now await update helpers for both standard and 4K flows and perform conditional bulk saves. Review transaction/manager usage and ordering of side effects.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Subscriber as MediaSubscriber
participant EM as EntityManager (event.manager)
participant Repo as MediaRequestRepo
participant DB as Database
Subscriber->>EM: beforeUpdate(event)
EM->>Repo: getRepository(MediaRequest)
Subscriber->>Repo: query pending/approved requests
Subscriber->>Repo: prepare updates (in-memory)
Subscriber->>Repo: save(updatedRequests) (await)
Subscriber->>EM: afterUpdate(event)
EM->>Repo: getRepository(SeasonRequest) / getRepository(MediaRequest)
Subscriber->>Repo: query related requests (4K / season)
Subscriber->>Repo: save(updatedRequests) (await)
Repo->>DB: persist changes

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

preview

Suggested reviewers

  • fallenbagel
  • 0xSysR3ll
  • gauthier-th

Poem

🐰 I hop through queues with careful paws,

Awaiting managers, obeying laws,
I batch the changes, save with care,
Requests completed — tidy affair. 🥕

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main change: adding await to async calls in MediaSubscriber lifecycle hooks to fix race conditions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

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)
server/subscriber/MediaSubscriber.test.ts (1)

17-27: Make the fixtures configurable for 4K coverage.

The implementation change also touches the 4K branches, but these helpers only build non-4K data (status only, is4k: false). Making them parameterizable would let this suite cover Line 140 and Line 195 too.

♻️ Suggested tweak
 async function createMedia(
   status: MediaStatus,
-  mediaType = MediaType.MOVIE
+  mediaType = MediaType.MOVIE,
+  status4k = MediaStatus.UNKNOWN
 ): Promise<Media> {
   const mediaRepo = getRepository(Media);
   const media = new Media();
   media.tmdbId = Math.floor(Math.random() * 900000) + 100000;
   media.mediaType = mediaType;
   media.status = status;
+  media.status4k = status4k;
   return mediaRepo.save(media);
 }

 async function createMovieRequest(
   requestedBy: User,
   media: Media,
-  status: MediaRequestStatus
+  status: MediaRequestStatus,
+  is4k = false
 ): Promise<MediaRequest> {
   const requestRepo = getRepository(MediaRequest);
   const req = new MediaRequest({
     media,
     requestedBy,
     status,
     type: MediaType.MOVIE,
-    is4k: false,
+    is4k,
   });
   return requestRepo.save(req);
 }

Also applies to: 29-42

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/subscriber/MediaSubscriber.test.ts` around lines 17 - 27, The test
helper createMedia currently only builds non‑4K Media (no way to set is4k), so
make it accept an optional is4k boolean (default false) and pass it into the
Media entity before saving; update any other fixture helpers referenced (the
ones around lines 29–42) similarly to accept an is4k parameter and set
media.is4k so tests can exercise the 4K branches (e.g., adjust createMedia and
the other helper functions to accept is4k: boolean = false and assign to
Media.is4k).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/subscriber/MediaSubscriber.ts`:
- Around line 133-140: The helper methods updateChildRequestStatus and
updateRelatedMediaRequest are using the global datasource repositories (via
getRepository) which causes writes to bypass the transaction on the incoming
event; change their signatures to accept the transaction EntityManager (pass
event.manager from the MediaSubscriber where you call updateChildRequestStatus)
and inside those methods use event.manager.getRepository(...) (or the passed
manager.getRepository) for all reads/writes so the MediaRequest/SeasonRequest
updates occur within the same transaction scope as the Media save.

---

Nitpick comments:
In `@server/subscriber/MediaSubscriber.test.ts`:
- Around line 17-27: The test helper createMedia currently only builds non‑4K
Media (no way to set is4k), so make it accept an optional is4k boolean (default
false) and pass it into the Media entity before saving; update any other fixture
helpers referenced (the ones around lines 29–42) similarly to accept an is4k
parameter and set media.is4k so tests can exercise the 4K branches (e.g., adjust
createMedia and the other helper functions to accept is4k: boolean = false and
assign to Media.is4k).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ce70c368-c2ad-4e4f-bb1e-95dbe5f4c228

📥 Commits

Reviewing files that changed from the base of the PR and between dbe1fca and 17e38a4.

📒 Files selected for processing (2)
  • server/subscriber/MediaSubscriber.test.ts
  • server/subscriber/MediaSubscriber.ts

…and add 4K test coverage

Pass the transaction-scoped event.manager into updateChildRequestStatus and
updateRelatedMediaRequest instead of resolving repositories via the global
getRepository(). This ensures that MediaRequest and SeasonRequest writes
participate in the same unit of work as the parent Media save: if the outer
save rolls back, the request status changes roll back with it.

Also removes the now-unused getRepository import from @server/datasource.

Test coverage is extended to include the 4K branches (status4k transitions)
for both the beforeUpdate and afterUpdate paths.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/subscriber/MediaSubscriber.ts (1)

19-39: Consider renaming parameter event to media for clarity.

The parameter event is actually a Media entity, not an UpdateEvent. This naming can cause confusion when reading the code. Additionally, the individual saves in the loop could be batched for efficiency, though this is less critical now that operations are within the same transaction.

♻️ Optional: Batch save and rename parameter
   private async updateChildRequestStatus(
     manager: EntityManager,
-    event: Media,
+    media: Media,
     is4k: boolean
   ) {
     const requestRepository = manager.getRepository(MediaRequest);

     const requests = await requestRepository.find({
-      where: { media: { id: event.id } },
+      where: { media: { id: media.id } },
     });

+    const toSave: MediaRequest[] = [];
     for (const request of requests) {
       if (
         request.is4k === is4k &&
         request.status === MediaRequestStatus.PENDING
       ) {
         request.status = MediaRequestStatus.APPROVED;
-        await requestRepository.save(request);
+        toSave.push(request);
       }
     }
+    if (toSave.length > 0) {
+      await requestRepository.save(toSave);
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/subscriber/MediaSubscriber.ts` around lines 19 - 39, Rename the
parameter `event` to `media` in the updateChildRequestStatus method signature
and all its usages to reflect that it is a Media entity (function:
updateChildRequestStatus, parameter: event -> media); also improve efficiency by
batching saves: collect the modified MediaRequest instances (from
requestRepository.find where media.id === media.id and matching is4k/status
checks) and call requestRepository.save once with the array instead of awaiting
saves inside the loop so the updates stay within the same transaction.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/subscriber/MediaSubscriber.ts`:
- Around line 19-39: Rename the parameter `event` to `media` in the
updateChildRequestStatus method signature and all its usages to reflect that it
is a Media entity (function: updateChildRequestStatus, parameter: event ->
media); also improve efficiency by batching saves: collect the modified
MediaRequest instances (from requestRepository.find where media.id === media.id
and matching is4k/status checks) and call requestRepository.save once with the
array instead of awaiting saves inside the loop so the updates stay within the
same transaction.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3feb4c3e-5601-4d6f-ae19-aba58640f0a7

📥 Commits

Reviewing files that changed from the base of the PR and between 17e38a4 and 26d295c.

📒 Files selected for processing (2)
  • server/subscriber/MediaSubscriber.test.ts
  • server/subscriber/MediaSubscriber.ts
✅ Files skipped from review due to trivial changes (1)
  • server/subscriber/MediaSubscriber.test.ts

- Rename event -> media parameter in updateChildRequestStatus for clarity
- Batch MediaRequest saves: collect changed requests then call
  requestRepository.save(toSave) once instead of one save per loop iteration
- Add optional status4k parameter to createMedia test helper
- Add optional is4k parameter to createMovieRequest test helper
- Restore settings.main.localLogin in finally block in authenticatedAgent
- Explicitly set cookie: { secure: false } in test session config
@dougrathbone
Copy link
Copy Markdown
Contributor Author

Addressed in the latest commit:

  • Renamed event to media parameter in updateChildRequestStatus for clarity
  • Batched the MediaRequest saves: modified requests are collected then saved in a single requestRepository.save(toSave) call
  • Added status4k parameter to createMedia test helper and is4k parameter to createMovieRequest helper so 4K branches can be exercised in tests

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/subscriber/MediaSubscriber.ts (1)

138-141: Consider adding error handling in upstream route handlers.

Now that beforeUpdate and afterUpdate properly await their helper methods, any errors (e.g., bulk save failures) will propagate up to the calling mediaRepository.save(). The POST /:id/:status route in server/routes/media.ts (around line 164) lacks a try-catch wrapper, so these errors will crash the handler. The DELETE route in the same file demonstrates the correct pattern with proper error handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/subscriber/MediaSubscriber.ts` around lines 138 - 141, The POST
/:id/:status route handler currently calls mediaRepository.save() without
catching errors that can bubble up from
MediaSubscriber.beforeUpdate/afterUpdate; wrap the handler body in a try-catch,
call await mediaRepository.save(...) inside the try, and in catch log the error
and return an appropriate HTTP error response (e.g., 500 with a JSON error
message) similar to the existing DELETE route pattern so upstream failures (bulk
save or subscriber errors) don't crash the process.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/subscriber/MediaSubscriber.ts`:
- Around line 138-141: The POST /:id/:status route handler currently calls
mediaRepository.save() without catching errors that can bubble up from
MediaSubscriber.beforeUpdate/afterUpdate; wrap the handler body in a try-catch,
call await mediaRepository.save(...) inside the try, and in catch log the error
and return an appropriate HTTP error response (e.g., 500 with a JSON error
message) similar to the existing DELETE route pattern so upstream failures (bulk
save or subscriber errors) don't crash the process.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44fa986c-22c2-4e13-826a-1a67ad75f79f

📥 Commits

Reviewing files that changed from the base of the PR and between 26d295c and 5e4fb7f.

📒 Files selected for processing (1)
  • server/subscriber/MediaSubscriber.ts

Copy link
Copy Markdown
Collaborator

@fallenbagel fallenbagel left a comment

Choose a reason for hiding this comment

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

Hey @dougrathbone, thanks for the PR but I'm noticing quite a lot of scope creep and unnecessary changes.

The core fix which was adding await to the subscriber lifecycle hooks as described in the pr description was already addressed in #2760, which has been merged. So the main motivation for this PR has already been resolved and now it's starting to creep out of scope.

The remaining part which is switching from getRepository() to event.manager for transaction scoping is also unfortunately incomplete.

This is because when the updateChildRequestStatus saves a MediaRequest, it triggers MediaRequestSubscriber.afterUpdate, and that entire subscriber (sendToRadarr, sendToSonarr, updateParentStatus, etc.) still uses global getRepository(). So the writes immediately break out of the transaction scope anyway, and the stated rollback guarantee doesn't actually really hold imo.

In addition, the event to media rename and toSave batching seems like minor cosmetic changes that don't add much value either, imo.

If we want to properly scope both subscribers to use transaction-bound managers, that'd be a larger effort and worth its own pr.

CC: @seerr-team/seerr-core

@dougrathbone
Copy link
Copy Markdown
Contributor Author

dougrathbone commented Apr 3, 2026

@fallenbagel thanks for the feedback.

understand that this might've seemed low value.

would you be open to me taking a pass at the bigger manager pr? would love to give it a go?

in general i'm just going through the codebase looking for things i can help with as i'm a long time fan (long time listener, first time caller).

if other areas might be worth a go i'd love the guidance

@fallenbagel
Copy link
Copy Markdown
Collaborator

would you be open to me taking a pass at the bigger manager pr? would love to give it a go?

Feel free to open a pr ofcourse!

For the time being I'll mark this PR as blocked (so it would be more organised and clear)

@fallenbagel fallenbagel added the blocked This issue can't be solved for now label Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked This issue can't be solved for now

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants