Skip to content

Performance: datahub export filter is O(n*m) over a 3x-duplicated subject id array #1412

Open

Description

@gdevenyi

Current Implementation and Limitations

handleExportSelection in apps/web/src/routes/_app/datahub/index.tsx:227 downloads the whole group's export from the server and then filters it in the browser down to the subjects currently listed in the table:

const listedSubjects = table
  .getPrePaginationRowModel()
  .rows.flatMap((row) => row.getVisibleCells().map((cell) => removeSubjectIdScope(cell.row.original.id)));

const filteredData = data.filter((dataEntry) => listedSubjects.includes(dataEntry.subjectId));

Three separate problems in these four lines:

1. listedSubjects contains one entry per visible cell, not per row. The flatMap iterates row.getVisibleCells() but ignores cell entirely and reads cell.row.original.id — the same value for every cell in the row. The master table has three data columns, so the array holds at least three identical copies of every subject id, and removeSubjectIdScope() is called three times per row to produce them.

2. listedSubjects.includes(...) inside data.filter(...) is O(n × m). includes on an array is a linear scan. With n export rows and m entries in listedSubjects, this is n × m string comparisons — and because of (1), m is already 3× larger than it needs to be.

3. The filtering happens on the client at all. The groupId is the only thing sent to the server (params: { groupId: currentGroup?.id }); every other active filter — the sex filter, the date-of-birth range, the search string, the "with records only" toggle — is applied after the full group export has crossed the network. A user who filters down to 10 subjects still downloads the export for all of them.

The same anti-pattern appears at logs.tsx:141 in the audit log download, which builds its payload from the client's in-memory row model.

Measurements

Combining with the measurements from the export endpoint issue: a group with 25,000 instrument records produces 750,000 export rows and a 111 MiB response body. The master table for that group lists 5,000 subjects, so listedSubjects holds 15,000 strings (3 × 5,000 duplicates).

That makes the client-side filter 750,000 × 15,000 = 1.1 × 10¹⁰ string comparisons in the worst case (a row whose subject is not listed scans the entire array). This runs synchronously on the main thread, after the browser has already parsed a 111 MiB msgpack payload. In practice the tab will be unresponsive for a long time or crash.

Deduplicating and using a Set reduces the same work to 750,000 hash lookups — a reduction of roughly four orders of magnitude — and is a two-line change.

Associated Application Components

Client, Server

Proposed Solution

1. Immediate, low-risk fix — dedupe and use a Set:

const listedSubjects = new Set(
  table.getPrePaginationRowModel().rows.map((row) => removeSubjectIdScope(row.original.id))
);

const filteredData = data.filter((dataEntry) => listedSubjects.has(dataEntry.subjectId));

This drops the redundant getVisibleCells() traversal, calls removeSubjectIdScope once per row instead of three times, and turns the O(n × m) filter into O(n). It changes no behaviour.

2. Real fix — filter server-side. Send the listed subject ids (or, better, the filter criteria themselves) to /v1/instrument-records/export so the server only produces the rows the user asked for. This composes directly with the streaming rework proposed in the export endpoint issue: with a subjectIds parameter the export becomes proportional to what is being exported rather than to the size of the group.

3. Apply the same fix to the audit log download (logs.tsx:140), which has the same "build the export from the client's row model" shape and additionally captures a stale table via useCallback(..., []).

Estimated Difficulty

Low

Priority

High

Activity

  1. added
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: LowIsolated change, about 2 hours or less
    on Jul 20, 2026
  2. joshunrau commented on Jul 20, 2026

    @joshunrau
    Collaborator

    @gdevenyi Claude can do this

  3. gdevenyi commented on Jul 20, 2026

    @gdevenyi
    ContributorAuthor

    Correction to the original report: the duplication factor is 4×, not 3×. I measured it against the real table rather than counting columns by eye — the master table has three data columns plus the row-actions column libui injects when rowActions is set:

    cellsPerRow=4  rows=5000
    oldArrayLength=20000  newSetSize=5000  factor=4.0
    

    #1425 fixes item 1, measured at 3,070 ms → 17 ms for 200,000 export rows against 5,000 listed subjects (identical results, 167,000 matched either way).

    Item 2 is still open — the filtering remains client-side, so every active filter is applied only after the whole group's export has crossed the network. That is better done alongside the streaming rework in #1407 than by adding a subjectIds parameter to an endpoint that is about to be restructured.

    Item 3 (the same pattern in the audit log download) is handled in #1419.

  4. added
    Difficulty: HighMore than a day, or touches several workspaces
    Area: Webapps/web (clinician-facing React SPA)
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    PerformanceSpeed, memory or scalability
    and removed
    Difficulty: LowIsolated change, about 2 hours or less
    on Sep 27, 2026
  5. joshunrau commented on Oct 26, 2026

    @joshunrau
    Collaborator

    Partially fixed on main (d86740e). Items 1 and 3 are done: #1425 (508e7ab) and #1419 have merged. Item 2 is still open. d4bf375 moved the handler to apps/web/src/routes/_app/datahub/subjects/index.tsx:342. It still sends only groupId (:334-336) and filters the response in the browser (:358-360).

    Item 2 does not need to wait for #1407

    The streaming in #1407 changes how rows leave the process, not what the request asks for. None of it has landed: c363e31 and e2007a7 edited the pipeline in place. Do item 2 as its own PR after #1807, which edits both handleExportSelection and queryRecordsRaw.

    It is also a correctness fix

    Both sides of the browser filter compare unscoped ids. getListedSubjectIds (apps/web/src/utils/table.ts:13-15) and the worker's subjectId (export-worker.js:103, :125) both go through removeSubjectIdScope, so ids that differ only in scope compare equal:

    Matching the stored subjectId in Mongo avoids both cases, whatever #1725 decides.

    Options

    1. Send the listed ids. The table's row model stays the one definition of what gets exported, the contract testing/src/specs/datahub.spec.ts:174 pins. The ids must go in a body. Node's default maxHeaderSize is 16 KiB and the URL counts toward it: a local probe gets a 431 at 200 hashed ids in axios's subjectIds[]= form. libnest 9.0.0 sets bodyLimit to 50 MiB (src/app/app.container.ts:31), and 5,000 hashed ids are about 335 KB of JSON.
    2. Send the criteria. The table filters on five things (index.tsx:474-479, :509-518, :532-542, :565-567, :584-594). The id search matches the unscoped id. The record count and last-collected date come from summarizeBySubject, which skips pending records (instrument-records.service.ts:375). The export would have to repeat that aggregation and reimplement removeSubjectIdScope in Mongo. Any drift between the two filters exports rows the table does not show.

    Recommendation: option 1. Option 2 pays off only if the subjects table itself moves to server-side filtering and paging, as the audit log did in #1419.

    Plan

    • packages/schemas/src/instrument-records/instrument-records.ts: add $ExportInstrumentRecordsData with groupId? and a required subjectIds with .min(1), so an empty list is a 400, not an empty export.
    • instrument-records.controller.ts: add @Post('export') with @HttpCode(HttpStatus.OK), the same @RouteAccess and MsgpackInterceptor, and the body schema imported as a value. Leave the GET (:78-84) as the whole-group export.
    • instrument-records.service.ts: pass subjectIds through exportRecords to queryRecordsRaw. Put subjectId: { $in: subjectIds } and the groupId condition in one $match ahead of the first $lookup (:590). subjectId leads @@index([subjectId, instrumentId, date]) (schema.prisma:187). That is the "$match first" half of Performance: /instrument-records/export buffers the whole dataset in memory (12.8s, +2.9 GiB RSS, 122 MiB body at 200k records) #1407 item 3, not the ability-derived half. Keep the ability filter at :693-696: the ids narrow the export, they do not authorize it.
    • Web: getListedSubjectIds returns row.original.id, the scoped id. getExportRecords posts { groupId, subjectIds }, and the data.filter goes. When the table lists nothing, show the existing "No entries to export" error without making a request.
    • Update the comments that describe the browser filter: datahub.spec.ts:171-173 and authorization.spec.ts:554-555.
    • Gotcha: apps/web/src/services/axios.ts:37 retries only get/head/options, so the POST loses the retry on dropped connections and 502/503/504 that the GET gets today. The export is read-only, so a new meta opt-in would be safe.

    Tests

    • Schemas: an empty subjectIds is rejected. Controller: the POST forwards subjectIds and the ability.
    • instrument-records.service.test.ts, beside the groupId $match test at :908: it('should match the requested subjects before the first $lookup, so a narrowed export joins only their records').
    • apps/web/src/utils/__tests__/table.test.tsx:71: "should strip the group scope" flips to keeping the scope.
    • subjects/__tests__/index.test.tsx: the axios mock (:24) and resolveExport (:130-132) stub only get. :444-454 asserts that the POST body carries only the listed subject. :466-475 and :505-515 stop testing a browser filter. Add a case where an empty table makes no request.
    • e2e: datahub.spec.ts:174 passes either way, because it reads the downloaded file. Capture the response with page.waitForResponse and assert that its bytes lack the filtered-out id, as authorization.spec.ts:577-579 does for group ids.

    Open questions

    • Should the GET go? After this change its only in-repo caller is authorization.spec.ts:572, and b82fe8b removed routes no client calls. External scripts may still use it.
    • The last-collected window selects subjects, and then their whole history is exported, including records outside the window. Should the window also bound the exported records?

    With option 1, the remaining work is Medium difficulty, not High.

    Triage note written with Claude Code. Code references point at main @ d86740e.

  6. added
    Difficulty: MediumStraightforward change, about a day or less
    and removed
    Difficulty: HighMore than a day, or touches several workspaces
    on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)Area: Webapps/web (clinician-facing React SPA)Difficulty: MediumStraightforward change, about a day or lessEnhancementType: an improvement to an existing feature, including performancePerformanceSpeed, memory or scalabilityPriority: HighSignificant or frequently hit user impact; target the next release or two

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions