Skip to content

perf(count-routes): parallelize sequential count queries with Promise.all#2805

Closed
dougrathbone wants to merge 5 commits intoseerr-team:developfrom
dougrathbone:dougrathbone/perf/parallel-count-queries
Closed

perf(count-routes): parallelize sequential count queries with Promise.all#2805
dougrathbone wants to merge 5 commits intoseerr-team:developfrom
dougrathbone:dougrathbone/perf/parallel-count-queries

Conversation

@dougrathbone
Copy link
Copy Markdown
Contributor

@dougrathbone dougrathbone commented Apr 2, 2026

Description

Both GET /issue/count and GET /request/count shared the same performance problem: they created a single mutable TypeORM QueryBuilder instance and called .where(...).getCount() on it in sequence. Each call awaited the previous one before starting, meaning 7 and 9 sequential database round trips respectively for what are entirely independent queries.

The issue count handler ran these one after another:

// Before - 7 sequential awaits
const query = issueRepository.createQueryBuilder('issue');
const totalCount = await query.getCount();
const videoCount = await query.where('issue.issueType = :issueType', { issueType: IssueType.VIDEO }).getCount();
// ... 4 more

The request count handler had the same pattern across 9 counts, with an unnecessary innerJoinAndSelect on the base query (loading the full media entity) even for counts that only filter on request.* columns.

Fix: Replace both handlers with Promise.all() over independent repository.count({ where: ... }) calls. The two request counts that genuinely need a join (processing and available, which filter on media.status/media.status4k) each use a shared approvedMediaStatusCount(isAvailable) helper to eliminate the duplicated QueryBuilder setup.

// After - all run concurrently
const [total, video, audio, subtitles, others, open, closed] = await Promise.all([
  issueRepository.count(),
  issueRepository.count({ where: { issueType: IssueType.VIDEO } }),
  issueRepository.count({ where: { issueType: IssueType.AUDIO } }),
  issueRepository.count({ where: { issueType: IssueType.SUBTITLES } }),
  issueRepository.count({ where: { issueType: IssueType.OTHER } }),
  issueRepository.count({ where: { status: IssueStatus.OPEN } }),
  issueRepository.count({ where: { status: IssueStatus.RESOLVED } }),
]);

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

Performance improvement

Endpoint Before After Improvement
GET /issue/count 7 sequential queries 7 concurrent queries Up to 7x faster (PostgreSQL); proportional to DB latency
GET /request/count 9 sequential queries + unnecessary JOIN on 7 of them 9 concurrent queries, JOIN only where needed Up to 9x faster (PostgreSQL)

The gain is most visible on PostgreSQL where each query has non-trivial round-trip latency. On SQLite (single connection, serialised writes) the queries still execute in order but the code is clearer and the unnecessary JOIN is eliminated regardless.

Both endpoints are called on page load for the admin dashboard and issue management pages, so the improvement is user-visible on every navigation to those pages.

How Has This Been Tested?

Integration tests using the real SQLite test database via setupTestDb().

server/routes/issue.test.ts (3 tests):

  • Returns zero counts when no issues exist
  • Counts issues by type (VIDEO, AUDIO, SUBTITLES, OTHER)
  • Counts issues by status (OPEN, RESOLVED)

server/routes/request.count.test.ts (4 tests):

  • Returns zero counts when no requests exist
  • Counts requests by type (movie, tv)
  • Counts requests by status (pending, approved, declined)
  • Counts processing and available correctly for HD and 4K requests -- seeds APPROVED requests against media with PROCESSING and AVAILABLE status on both standard and 4K status columns, asserting the join path is correct for both is4k = false and is4k = true

To run:

node server/test/index.mts server/routes/issue.test.ts
node server/test/index.mts server/routes/request.count.test.ts

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

  • Tests

    • Added integration tests for issue and request count endpoints, validating totals by type and status, processing vs available counts (including 4K scenarios), and zero-state responses.
  • Refactor

    • Count endpoints rewritten to run independent count operations concurrently for the same response fields and behavior, improving query isolation and efficiency.

@dougrathbone dougrathbone requested a review from a team as a code owner April 2, 2026 11:19
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Refactored two GET /count handlers to run multiple repository count operations concurrently via Promise.all, and added integration tests for /issue/count and /request/count covering empty, type-based, status-based, and processing/availability scenarios.

Changes

Cohort / File(s) Summary
New Tests
server/routes/issue.test.ts, server/routes/request.test.ts
Added integration tests that boot an Express app, seed test DB, and verify /issue/count and /request/count for empty state, aggregation by type, aggregation by status, and processing vs available logic (HD/4K variants).
Issue Route
server/routes/issue.ts
Rewrote GET /count to perform multiple issueRepository.count({ where: ... }) calls concurrently with Promise.all; renamed unused req to _req. Response shape unchanged but values come from concurrent counts.
Request Route
server/routes/request.ts
Replaced a single mutating query-builder flow with concurrent requestRepository.count() calls via Promise.all. Added approvedMediaStatusCount(isAvailable) to compute processing/available using joins and media status logic. Response keys unchanged but use the new computed variables.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    rect rgba(220,240,255,0.5)
    participant Client
    end
    rect rgba(200,255,200,0.5)
    participant Server as Express Route Handler
    end
    rect rgba(255,230,200,0.5)
    participant DB as Repository/Database
    end

    Client->>Server: GET /issue/count or /request/count
    Server->>DB: count(where: total) (concurrent)
    Server->>DB: count(where: type=...) (concurrent)
    Server->>DB: count(where: status=...) (concurrent)
    Server->>DB: approvedMediaStatusCount(isAvailable) (concurrent, request route)
    DB-->>Server: counts (Promise.all resolves)
    Server-->>Client: 200 OK { total, byType..., byStatus..., processing, available }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • fallenbagel
  • gauthier-th
  • M0NsTeRRR

Poem

🐰 Hopping through Promise.all fields so wide,

I count each bug and tuck it aside.
Tests nibble errors till all are gone,
Parallel carrots from dusk to dawn.
Thump-thump—routes race light and spry!

🚥 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 title accurately describes the main change: parallelizing sequential count queries using Promise.all across both the issue and request route handlers.

✏️ 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/routes/request.ts (1)

365-386: Consider extracting the duplicated approved+media-status count query shape.

Both processing and available counts repeat the same join/base filter and only differ by operator; a tiny helper will reduce drift risk later.

♻️ Optional refactor sketch
+const countApprovedByAvailability = (isAvailable: boolean) =>
+  requestRepository
+    .createQueryBuilder('request')
+    .innerJoin('request.media', 'media')
+    .where('request.status = :status', {
+      status: MediaRequestStatus.APPROVED,
+    })
+    .andWhere(
+      isAvailable
+        ? '(request.is4k = false AND media.status = :available) OR (request.is4k = true AND media.status4k = :available)'
+        : '(request.is4k = false AND media.status != :available) OR (request.is4k = true AND media.status4k != :available)',
+      { available: MediaStatus.AVAILABLE }
+    )
+    .getCount();
...
-      requestRepository
-        .createQueryBuilder('request')
-        ...
-        .getCount(),
-      requestRepository
-        .createQueryBuilder('request')
-        ...
-        .getCount(),
+      countApprovedByAvailability(false),
+      countApprovedByAvailability(true),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/request.ts` around lines 365 - 386, The two count queries
duplicate the same base join/filter
(requestRepository.createQueryBuilder('request') with
.innerJoin('request.media','media') and .where('request.status = :status', {
status: MediaRequestStatus.APPROVED }) ) and only differ in the media-status
predicate (the AND clause checking request.is4k vs media.status/media.status4k
against MediaStatus.AVAILABLE); extract a small helper (e.g.,
buildApprovedMediaStatusQuery or approvedMediaStatusCountQuery) that returns a
QueryBuilder or accepts a boolean/operator to apply either the "!= :available"
or "= :available" predicate and then call .getCount() on its result to replace
both occurrences, keeping parameter name MediaStatus.AVAILABLE and the
request.is4k/media.status4k checks intact.
🤖 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/routes/request.count.test.ts`:
- Around line 83-99: The tests around createMediaRequest and the /request/count
suite don’t assert non-zero counts or verify 4K-specific logic; update the tests
that call createMediaRequest (and the ones in the 142-181 block) to create
explicit requests for both is4k=true and is4k=false with differing
MediaRequestStatus values (e.g., PROCESSING, AVAILABLE), then call the
/request/count endpoint and assert that the returned processing and available
counts for both regular and 4K buckets are > 0 (and that the 4K counts reflect
requests where MediaRequest.is4k was set), ensuring the path that produces
status4k/status for 4K media is exercised and verified.

---

Nitpick comments:
In `@server/routes/request.ts`:
- Around line 365-386: The two count queries duplicate the same base join/filter
(requestRepository.createQueryBuilder('request') with
.innerJoin('request.media','media') and .where('request.status = :status', {
status: MediaRequestStatus.APPROVED }) ) and only differ in the media-status
predicate (the AND clause checking request.is4k vs media.status/media.status4k
against MediaStatus.AVAILABLE); extract a small helper (e.g.,
buildApprovedMediaStatusQuery or approvedMediaStatusCountQuery) that returns a
QueryBuilder or accepts a boolean/operator to apply either the "!= :available"
or "= :available" predicate and then call .getCount() on its result to replace
both occurrences, keeping parameter name MediaStatus.AVAILABLE and the
request.is4k/media.status4k checks intact.
🪄 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: c1e74b2d-6d3c-445c-90d9-0d7b4329e1d8

📥 Commits

Reviewing files that changed from the base of the PR and between 868430b and c4954cc.

📒 Files selected for processing (4)
  • server/routes/issue.test.ts
  • server/routes/issue.ts
  • server/routes/request.count.test.ts
  • server/routes/request.ts

Comment thread server/routes/request.test.ts
@dougrathbone
Copy link
Copy Markdown
Contributor Author

Fixed in the latest commit:

  • 4K/processing/available test coverage: added a fourth test that seeds APPROVED requests for HD media at PROCESSING status, HD media at AVAILABLE status, 4K media at PROCESSING status4k, and 4K media at AVAILABLE status4k -- then asserts processing === 2 and available === 2. This directly exercises the is4k branch in the join query.
  • Duplicated QueryBuilder: extracted approvedMediaStatusCount(isAvailable: boolean) inline in the route handler -- both processing and available now call it with false/true respectively.
  • Settings leak + secure cookie: applied the same authenticatedAgent save/restore fix and explicit cookie: { secure: false } as raised in the other PRs.

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/routes/request.count.test.ts (1)

1-2: Use a non-literal session secret in tests to reduce secret-scanner noise.

Hard-coded literal secrets in tests invite security scanners to flag them and risk accidental copy/paste into production code. This pattern appears in multiple test files; consider using an environment variable or random value.

♻️ Suggested change
+import { randomBytes } from 'node:crypto';
 import assert from 'node:assert/strict';
 import { before, describe, it } from 'node:test';
 
 describe('GET /request/count', () => {
   let app: Express;
   
   before(async () => {
     app.use(
       session({
-        secret: 'test-secret',
+        secret:
+          process.env.TEST_SESSION_SECRET ?? randomBytes(32).toString('hex'),
         resave: false,
         saveUninitialized: false,
         cookie: { secure: false },
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/request.count.test.ts` around lines 1 - 2, The test is using a
hard-coded session secret literal which triggers secret scanners; update the
test to use a non-literal secret by setting process.env.TEST_SESSION_SECRET (or
similar) in the before() hook and falling back to a generated value (e.g., use
crypto.randomBytes(...).toString('hex')) for the session secret used by the
app/session middleware; ensure you import node:crypto if generating and replace
any hard-coded string occurrences (sessionSecret, SESSION_SECRET, or where you
pass the secret into your session setup) to read from
process.env.TEST_SESSION_SECRET so the test no longer contains a literal secret.
🤖 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/routes/request.count.test.ts`:
- Around line 1-2: The test is using a hard-coded session secret literal which
triggers secret scanners; update the test to use a non-literal secret by setting
process.env.TEST_SESSION_SECRET (or similar) in the before() hook and falling
back to a generated value (e.g., use crypto.randomBytes(...).toString('hex'))
for the session secret used by the app/session middleware; ensure you import
node:crypto if generating and replace any hard-coded string occurrences
(sessionSecret, SESSION_SECRET, or where you pass the secret into your session
setup) to read from process.env.TEST_SESSION_SECRET so the test no longer
contains a literal secret.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3701d403-2d98-4b66-901f-09f7e825dcc1

📥 Commits

Reviewing files that changed from the base of the PR and between c4954cc and d00005e.

📒 Files selected for processing (2)
  • server/routes/request.count.test.ts
  • server/routes/request.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/routes/request.ts

Comment on lines +57 to +65
async function authenticatedAgent(email: string, password: string) {
const agent = request.agent(app);
const settings = getSettings();
settings.main.localLogin = true;

const res = await agent.post('/auth/local').send({ email, password });
assert.strictEqual(res.status, 200);
return agent;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

same issue here as i flagged in your other PR.

Comment thread server/routes/request.ts Outdated

try {
const query = requestRepository
const approvedMediaStatusCount = (isAvailable: boolean) =>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This helper builds a QueryBuilder but lives outside the try block. I would recommend moving it inside so any unexpected throw is caught by the shared error handler.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This should be request.test.ts the /count route is part of request route, and splitting test files per endpoint will get unwieldy.

@github-actions github-actions Bot added the merge conflict Cannot merge due to merge conflicts label Apr 14, 2026
@github-actions
Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.

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

🤖 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/routes/request.ts`:
- Around line 349-353: The AND/OR precedence in the .andWhere(...) expression
causes the approval filter to apply only to the HD branch; fix the
.andWhere(...) clause (the block that uses isAvailable, request.is4k and
MediaStatus.AVAILABLE) by grouping the media-status checks into a single
parenthesized predicate so the approval condition scopes both branches—i.e. wrap
the media.status and media.status4k comparisons together for both the '=
:available' and '!= :available' cases while keeping the request.is4k checks as
separate branches.
🪄 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: beb20f33-4dcc-4691-b959-397a162998bb

📥 Commits

Reviewing files that changed from the base of the PR and between d00005e and 100a0bf.

📒 Files selected for processing (2)
  • server/routes/request.test.ts
  • server/routes/request.ts

Comment thread server/routes/request.ts
@dougrathbone
Copy link
Copy Markdown
Contributor Author

Addressed the feedback:

  • Moved the QueryBuilder helper inside the try block so unexpected throws are caught by the error handler
  • Renamed request.count.test.ts to request.test.ts to keep tests grouped by route

@dougrathbone dougrathbone force-pushed the dougrathbone/perf/parallel-count-queries branch from 46ca789 to 005800c Compare April 14, 2026 09:40
@github-actions github-actions Bot removed the merge conflict Cannot merge due to merge conflicts label Apr 14, 2026
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/routes/request.test.ts (1)

133-149: Derive type from media.mediaType in this fixture helper.

Passing both makes it easy for a future test to create an impossible MediaRequest/Media pair. Using the media row as the source of truth keeps these count fixtures realistic.

Suggested cleanup
 async function createMediaRequest(
   user: User,
   media: Media,
-  type: MediaType,
   status: MediaRequestStatus = MediaRequestStatus.PENDING,
   is4k = false
 ): Promise<MediaRequest> {
   const requestRepository = getRepository(MediaRequest);
   const req = new MediaRequest({
-    type,
+    type: media.mediaType,
     media,
     requestedBy: user,
     status,
     is4k,
     seasons: [],
   });
   return requestRepository.save(req);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/request.test.ts` around lines 133 - 149, The fixture helper
createMediaRequest should derive the MediaRequest.type from the provided media
instead of accepting an external type to avoid invalid pairs; update
createMediaRequest (and its callers) to stop passing a separate type parameter
and use media.mediaType when constructing the new MediaRequest (i.e., remove the
type parameter from the signature or ignore it and assign type = media.mediaType
before creating the MediaRequest instance), and ensure any tests that called
createMediaRequest are updated to match the new signature.
🤖 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/routes/request.ts`:
- Around line 342-380: The count buckets currently hard-code request.status =
APPROVED and model processing/available differently than the GET /request
filter; update approvedMediaStatusCount (and its callers) to match the GET
/request semantics: for "processing" count only requests with status = APPROVED
whose media status (or media.status4k when request.is4k is true) is one of
[UNKNOWN, PENDING, PROCESSING, PARTIALLY_AVAILABLE] and exclude
deleted/blocklisted statuses the GET endpoint filters out; for "available" count
requests whose media status/status4k is either COMPLETED or AVAILABLE. Implement
this by changing approvedMediaStatusCount to accept an allowedStatuses array (or
two named helpers) and use IN clauses on media.status / media.status4k instead
of the current boolean branch, ensuring the same status sets used by GET
/request are reused so the two endpoints stay consistent.

---

Nitpick comments:
In `@server/routes/request.test.ts`:
- Around line 133-149: The fixture helper createMediaRequest should derive the
MediaRequest.type from the provided media instead of accepting an external type
to avoid invalid pairs; update createMediaRequest (and its callers) to stop
passing a separate type parameter and use media.mediaType when constructing the
new MediaRequest (i.e., remove the type parameter from the signature or ignore
it and assign type = media.mediaType before creating the MediaRequest instance),
and ensure any tests that called createMediaRequest are updated to match the new
signature.
🪄 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: 46476d24-60ac-4140-8c3e-b1ab8c56b530

📥 Commits

Reviewing files that changed from the base of the PR and between 46ca789 and 005800c.

📒 Files selected for processing (4)
  • server/routes/issue.test.ts
  • server/routes/issue.ts
  • server/routes/request.test.ts
  • server/routes/request.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/routes/issue.ts

Comment thread server/routes/request.ts
….all

Replace sequential await chains in GET /issue/count and GET /request/count
with Promise.all() so all independent count queries execute concurrently,
reducing N sequential DB round trips to a single parallel batch. Add
integration test suites for both endpoints.
- Add is4k parameter to createMediaRequest helper (defaults false)
- Add test 'counts processing and available correctly for HD and 4K
  requests' -- seeds APPROVED requests against media with PROCESSING
  and AVAILABLE status on both the standard and 4K status columns,
  then asserts the /request/count endpoint returns the correct split
- Extract approvedMediaStatusCount(isAvailable) helper in the route
  handler to eliminate the duplicated QueryBuilder setup for the
  processing/available counts
- Restore settings.main.localLogin in a finally block in authenticatedAgent
- Explicitly set cookie: { secure: false } on the test session
Per review feedback - count route tests belong in the main request test
file rather than a separate file per endpoint.
The original code on develop uses double-parens to group the OR
branches so the APPROVED status filter scopes both HD and 4K. Our
refactored helper dropped the outer parens, which would let SQL AND
precedence cause the approval filter to apply only to the HD branch.
@dougrathbone dougrathbone force-pushed the dougrathbone/perf/parallel-count-queries branch from 005800c to e45c2dc Compare April 17, 2026 02:24
@gauthier-th
Copy link
Copy Markdown
Member

Closing as discussed here.

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.

3 participants