Skip to content

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

Open
dougrathbone wants to merge 2 commits intoseerr-team:developfrom
dougrathbone:dougrathbone/perf/parallel-count-queries
Open

perf(count-routes): parallelize sequential count queries with Promise.all#2805
dougrathbone wants to merge 2 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 comprehensive integration tests for issue and request count endpoints, covering totals, type breakdowns, status buckets, and HD/4K availability scenarios.
  • Refactor

    • Improved count endpoints to compute multiple metrics concurrently for more efficient and reliable responses.

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

📝 Walkthrough

Walkthrough

Added integration tests for /issue/count and /request/count, and refactored both route handlers to compute counts concurrently using Promise.all and simpler count()/query-builder calls; minor handler signature change in the issue route (req_req).

Changes

Cohort / File(s) Summary
Issue count tests & route
server/routes/issue.test.ts, server/routes/issue.ts
New integration tests for GET /issue/count and refactored GET /issue/count to run multiple issueRepository.count() calls concurrently via Promise.all. Handler unused req renamed to _req.
Request count tests & route
server/routes/request.count.test.ts, server/routes/request.ts
New integration tests for GET /request/count. Refactored GET /request/count to compute most counts with concurrent requestRepository.count() calls and use query-builder joins for processing/available logic. Error handling unchanged.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • fallenbagel
  • gauthier-th

Poem

🐰 I hopped through counts both loud and small,
Parallel trails—no wait at all,
Tests planted seeds to prove the run,
Queries leap together — swift and fun,
A carrot-coded victory for everyone 🥕

🚥 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 directly and accurately summarizes the main performance improvement: parallelizing sequential count queries with Promise.all across both /issue/count and /request/count routes.

✏️ 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

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


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.

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