Skip to content

perf(api): group the has-record subject ids in the database - #1426

Open
gdevenyi wants to merge 5 commits into
mainfrom
perf/has-record-filter
Open

perf(api): group the has-record subject ids in the database#1426
gdevenyi wants to merge 5 commits into
mainfrom
perf/has-record-filter

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #1413.

The "with records only" datahub filter resolved its subject list with:

findMany({ distinct: ['subjectId'], select: { subjectId: true }, where })

Prisma applies distinct in the query engine, not in MongoDB, so the driver receives one row per record and collapses them to one row per subject only after they have arrived. On a group holding 100k records that is 100k rows transferred to produce a list of at most a few thousand ids.

groupBy asks MongoDB for the distinct values directly.

Measured over HTTP, live instance, 200,300 records, median of 7

main this branch
GET /v1/subjects?hasRecord=true 366 ms 42 ms
GET /v1/subjects?groupId=<g>&hasRecord=true 67 ms 37 ms
GET /v1/subjects?groupId=<g> (filter off, for reference) 4 ms 3 ms

The unscoped case is where the difference shows, and it widens with the collection: groupBy returns one row per subject regardless of how many records back it, so its cost is flat in records-per-subject where the old form was linear.

Confirmed at the query level too — the same comparison run directly through the Prisma client:

findMany distinct (current impl), scoped        197ms  -> 55 ids
findMany distinct (current impl), unscoped     1229ms  -> 100 ids
groupBy by subjectId, scoped                     95ms  -> 55 ids
groupBy by subjectId, unscoped                  100ms  -> 100 ids

Note distinct going 197 → 1229 ms as the scope widens while groupBy stays at ~100 ms.

The issue's "better still" suggestion is wrong — please don't take it

#1413 floated expressing this as a relation filter on Subject:

{ instrumentRecords: { some: groupId ? { groupId } : {} } }

and said it was "the version worth trying first". I measured it on the same dataset and it takes 26,849 ms — 270× slower than the code being replaced, let alone the replacement:

subject.findMany({instrumentRecords:{some}})   26849ms  -> 55 subjects

Prisma compiles a relation filter on MongoDB into a $lookup over the whole record collection. That is the same construct that fails outright at scale in #1416 (fixed in #1423 by removing it), and it carries the same 100 MiB per-document ceiling. I've left a comment on the method saying so, and corrected the issue.

Also fixed: the record query now respects the caller's ability

querySubjectIdsWithRecords ignored ability entirely. The outer subject query already constrained the result, so this was not a data leak — but the inner query read records the caller had no permission to read, and was inconsistent with every other query in the service.

Tests

The three existing hasRecord tests are updated to the new call and still assert the same outcomes. Two added:

  • the ids are grouped in the database, asserting groupBy is used and findMany is not — a guard against the distinct form coming back;
  • the record query carries the caller's ability rather than being unconstrained.

Checks

  • tsc --noEmit on apps/api — clean
  • eslint src — clean
  • prettier --check — clean
  • vitest run (whole workspace) — 254 passed, 1 skipped, 1 failed

The one failure is instrument-records.service.spec.ts > upload > should create records with pending set. This branch is cut from main so it carries that pre-existing failure; #1422 fixes it.


Rebased onto main, review item addressed

Rebased onto 26ec89168; CI green.

Copilot: expect(call.where.AND[0]).not.toStrictEqual({}) did not prove anything. Correct, and
worse than it looks — an unconditional read rule legitimately produces {}, so that assertion would
have passed against a query that ignored the caller entirely. The test now builds a conditioned
ability and compares the clause against accessibleQuery(ability, 'read', 'InstrumentRecord')
directly, with accessibleQuery imported as suggested.

Verified by mutation: passing undefined in place of the ability now fails the test, where before it
passed.


Round 2: rebased onto 5bfe4b2dc, review items addressed

1. The 500 — confirmed and fixed. Reproduced exactly as you described: with a STANDARD ability,
accessibleQuery(ability, 'read', 'InstrumentRecord') throws
ForbiddenError: It's not allowed to run "read" on "InstrumentRecord" rather than returning {}.
querySubjectIdsWithRecords now returns an empty list for a caller holding no such rule, before
accessibleQuery is reached — the contract you settled on. The scoping itself stays.

2. A unit test for that caller. Added, asserting the empty list rather than a throw, with the
ability built through AbilityFactory with a STANDARD payload. Verified by mutation: removing the
guard fails it with that ForbiddenError.

3. End-to-end. datahub.spec.ts now toggles "With records only" and asserts the table empties,
plus an API-level case asserting a standard user gets an answer rather than a 500. Both verified by
mutation. This needed the datahub-filter-has-records and datahub-filters-trigger testids and
filter locators you flagged as missing.

The narrowing test has to know exactly what its group holds, and the roleAccount group is shared by
every spec in a worker, so this adds an isolatedGroupManager fixture. #1425 introduces the same
fixture
— whichever merges second should drop its copy.

4. The not.toHaveBeenCalled() assertion. Dropped, agreed — the groupBy assertion above it
already rules out the distinct form.

The comment recording why the relation filter was rejected is kept, as you asked. Group-visibility
consequence stays as decided.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR improves the performance of the API’s “with records only” subject filter by moving subject-id deduplication into MongoDB via Prisma groupBy, avoiding Prisma’s client-side distinct behavior that transfers one row per record over the wire. It also updates the query to respect the caller’s ability when resolving subject ids from instrument records.

Changes:

  • Replace findMany({ distinct: [...] }) with instrumentRecord.groupBy({ by: ['subjectId'] }) when resolving “subjects with records”.
  • Apply accessibleQuery(ability, 'read', 'InstrumentRecord') to the record-side query for consistency and correctness.
  • Update and extend unit tests to guard against regressions (ensuring groupBy is used and ability is threaded through).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
apps/api/src/subjects/subjects.service.ts Switch subject-id resolution to Prisma groupBy and apply ability to the instrument record query.
apps/api/src/subjects/tests/subjects.service.spec.ts Update existing hasRecord tests for groupBy and add regression tests for groupBy usage and ability propagation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/api/src/subjects/__tests__/subjects.service.spec.ts Outdated
Comment thread apps/api/src/subjects/__tests__/subjects.service.spec.ts
@joshunrau

Copy link
Copy Markdown
Collaborator

CI is red on this branch, so I have not reviewed it yet.

Failing check: lint-and-testrun 29771462892

FAIL  api  src/instrument-records/__tests__/instrument-records.service.spec.ts
  > InstrumentRecordsService > upload > should create records with pending set,
    so they are not excluded from queries filtering on the field
AssertionError: expected "vi.fn()" to be called with arguments: [ { data: [ ObjectContaining{ pending: false } ] } ]

That test is not in your diff and no longer exists on main — it was removed in 722f0a0b0 ("test(api): update stale pending-on-create assertion for instrument records", 2026-07-20 22:39 EDT), which landed after this run. The result is stale.

Please merge or rebase onto current main and push to re-run CI. I'll pick the review back up once it's green.

@joshunrau

Copy link
Copy Markdown
Collaborator

Suggested model: Sonnet 5

Sizing the remaining work on this PR for whoever picks it up, human or agent. The branch is mergeable and the only blocker is stale CI, so remediation is merging main and pushing.

@joshunrau joshunrau left a comment

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.

Requesting changes so this tracks as needing author action.

Blocking: stale CI only — the branch is mergeable. Details in my earlier comment: the failing test isn't in your diff and was removed from main in 722f0a0b0, after this run was created. Merge main and push to get a fresh result.

No content review has been done on this branch yet — it's gated on CI being green.

@gdevenyi
gdevenyi force-pushed the perf/has-record-filter branch from 3cf2cba to 53c8db8 Compare July 27, 2026 18:59
@gdevenyi
gdevenyi requested a review from joshunrau July 27, 2026 19:28

@joshunrau joshunrau left a comment

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.

The perf work is right and the evidence for it is good — groupBy genuinely pushes the distinct into
MongoDB where distinct did not, and the comment recording why the instrumentRecords: { some: ... }
relation filter was rejected is worth more than the code change itself. Please keep it. The
accessibleQuery pin in the new test is also a real improvement over the not.toStrictEqual({})
version.

One blocking problem, in the other half of the change:

  1. accessibleQuery(ability, 'read', 'InstrumentRecord') at apps/api/src/subjects/subjects.service.ts:178
    throws when the caller holds no read rule for InstrumentRecord. @casl/prisma's
    accessibleBy proxy raises ForbiddenError rather than returning {} in that case, and every
    STANDARD user is in that case — ability.factory.ts grants them create on InstrumentRecord but
    never read. This route's guard is @RouteAccess({ action: 'read', subject: 'Subject' }), which
    STANDARD users pass, so they reach the service and the CASL error escapes as a 500 (it is not
    an HttpException, so libnest's filter maps it to Internal Server Error). On main the same
    request returns a correct 200. I reproduced this by driving SubjectsService.find({ hasRecord: true })
    with an ability built from AbilityFactory.createForPayload({ basePermissionLevel: 'STANDARD', ... }).

    Nobody hits it through the web UI — useNavItems.ts:63 hides the datahub entirely without
    read InstrumentRecord — but GET /v1/subjects?hasRecord=true is a documented endpoint and the
    guard admits these callers. Every other accessibleQuery(..., 'InstrumentRecord') in the codebase
    sits behind a route guard naming the same subject, which is why this shape has not bitten before.

    Joshua has settled the contract: such a caller should get an empty list — the honest reading
    of "subjects whose records you can see" — not a 403 and not the pre-PR behaviour of ignoring
    record permissions here. So the no-rule case needs handling before accessibleQuery is reached,
    or the ForbiddenError catching, with an empty result returned either way.

  2. Please add a unit test for that caller, asserting the empty list rather than a throw. The new
    ability test builds a permission set that can read InstrumentRecord, so it exercises the
    conditioned branch but never the no-rule branch — which is exactly the one that breaks.

  3. There is no e2e test, which the root CLAUDE.md asks for. testing/src/specs/datahub.spec.ts
    only checks that the page header renders. Toggling "With records only" and asserting the table
    narrows would cover both the perf path and the permissions change. The checkbox at
    apps/web/src/routes/_app/datahub/index.tsx:169 has no data-testid and
    testing/src/pages/_app/datahub/index.page.ts exposes no filter locator, so both need adding.

  4. Minor: expect(prismaClient.instrumentRecord.findMany).not.toHaveBeenCalled() in
    subjects.service.spec.ts:105 asserts an implementation detail. The groupBy assertion just above
    it already stops the distinct form coming back, so the negative one mostly adds a way for an
    unrelated future refactor to fail the test.

On the other consequence of the scoping — a group manager no longer seeing subjects whose records
live only in groups they cannot read — Joshua has confirmed that is intended, as a disclosure fix
consistent with how /v1/instrument-records already scopes. Nothing to change there.

If you hand this to Claude Code, Fable 5 is the right size — it is auth and group-scoping work,
and the fix has to be carried consistently through the service, its unit tests and a new e2e spec.

Reviewed at commit 53c8db8.

@gdevenyi
gdevenyi force-pushed the perf/has-record-filter branch from 53c8db8 to f68c7ef Compare July 28, 2026 17:25
gdevenyi added a commit that referenced this pull request Jul 28, 2026
Which records reach an exported file is decided client-side, from the rows
the master table is currently listing. Nothing covered that: `datahub.spec.ts`
asserted only that the page header renders, so getting the scoping wrong
would have handed the user another subject's data with no test failing.

Seeds two subjects with records, filters the table down to one, exports
JSON and asserts the payload names only that subject. Verified by mutation
-- reading the core row model instead of the pre-pagination one, which
ignores the filter, fails it.

The test needs to know exactly what its group holds, and the `roleAccount`
group is shared by every spec in a worker, so this adds an
`isolatedGroupManager` fixture that authenticates into a group of its own,
plus `uploadRecords` and `findInstrumentIdByName` on the api client for
seeding a subject that has a record at all.

Also from review: the fixture builds a complete `Subject` rather than
casting `{ id }` into one.

Note: #1426 introduces the same `isolatedGroupManager` fixture, so whichever
of the two merges second should drop its copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
@gdevenyi
gdevenyi requested a review from joshunrau July 28, 2026 18:02

@joshunrau joshunrau left a comment

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.

Thanks — everything from the last round landed, lint is clean across all 33 workspaces here and the api
suite passes 156/156. The scoping question this raised has been settled in favour of what you wrote:
ungrouped (admin-created) records should not count toward "with records only" for a group manager,
so no change to the filter is wanted.

Worth stating plainly, because it makes this more than a perf change: on main this subquery ran with
no ability filter at all (where: groupId ? { groupId } : {}), so a group manager's "has records"
answer was computed from every record in the collection. Adding accessibleQuery here fixes that.

One thing to add before merge:

  1. apps/api/src/subjects/subjects.service.ts:182-187 — please note in the doc comment that the
    accessibleQuery clause drops records with a null groupId, since a group manager's rule matches
    groupId: { in: groupIds } and Prisma's in does not match null. It is the behaviour we want, but
    it is invisible at the call site and it is precisely why admin-created records disappear from a
    manager's filtered view. The block already explains the STANDARD short-circuit just above, so this
    is the same pattern in the same place.

No change needed for STANDARD users, by the way — I checked, and both datahub entries in
useNavItems.ts are already gated on read InstrumentRecord, so they have no UI path to the toggle.

On ordering: this one is planned to merge first, ahead of #1425 and #1427. Nothing for you to do about
the shared isolatedGroupManager fixture — the later PRs will drop their copies.

If you hand this to Claude Code, Sonnet 5 is the right size — a single comment in one file,
mirroring the explanatory comment already three lines above it.

Reviewed at commit 3bd9380.

gdevenyi and others added 5 commits July 28, 2026 18:06
The "with records only" datahub filter resolved its subject list with

    findMany({ distinct: ['subjectId'], select: { subjectId: true }, where })

but prisma applies `distinct` in the query engine rather than pushing it
into mongodb, so the driver receives one row per *record* and collapses
them to one row per subject only after they have arrived. On a group
holding 100k records that is 100k rows transferred to produce a list of
at most a few thousand ids.

`groupBy` asks mongodb for the distinct values directly. Measured over
HTTP against a live instance with 200,300 records, median of 7:

  GET /v1/subjects?hasRecord=true                366ms -> 42ms
  GET /v1/subjects?groupId=<g>&hasRecord=true     67ms -> 37ms

The unscoped case is where the difference shows, and it widens with the
size of the collection: `groupBy` returns one row per subject regardless
of how many records back it.

The record query also now carries the caller's ability, which it
previously ignored. The outer subject query already constrained the
result, so this was not a data leak, but the inner query read records the
caller had no permission to read.

Note for anyone revisiting this: the issue also floated expressing the
filter as `instrumentRecords: { some: ... }` on Subject. Measured on the
same dataset that takes 26,849ms -- prisma compiles a relation filter on
mongodb into a $lookup over the whole record collection. It is 270x
slower than the code being replaced here, and carries the 100 MiB
per-document ceiling that made the same construct fail outright in #1416.

Refs #1413

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfen9VQemZanJLXinJ6MMG
`not.toStrictEqual({})` did not prove the ability was applied: an unconditional
read rule legitimately produces `{}`, so the assertion would have passed on a
query that ignored the caller entirely. Constrain the ability with conditions
and compare the clause against accessibleQuery directly.

Verified by mutation: passing `undefined` in place of the ability now fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
…ecords

Applying the caller's ability to the record query introduced a new failure
on this endpoint. `accessibleQuery` does not return an empty filter when
the ability holds no rule for the subject at all -- only an `undefined`
ability does that -- it throws a CASL ForbiddenError, which is not an
HttpException, so libnest's filter renders it a 500.

A STANDARD user holds `create` but not `read` on InstrumentRecord, and
`GET /v1/subjects` is gated on `read Subject`, which they do hold. So
`?hasRecord=true` returned an Internal Server Error where main returns
200. The web UI hides the datahub from those users, but the endpoint is
documented and its guard admits them.

Return an empty list for such a caller: the honest reading of "subjects
whose records you can see". The scoping itself stays.

Also from review:

- a unit test for the no-rule caller, built through `AbilityFactory` with a
  STANDARD payload rather than by hand, since a hand-built ability tends to
  carry the very rule whose absence breaks this
- e2e coverage in `datahub.spec.ts`, which had only asserted the page
  header: one case toggling "With records only" and asserting the table
  empties, one asserting the endpoint answers a standard user. Both
  verified by mutation
- dropped `expect(findMany).not.toHaveBeenCalled()`, which pinned an
  implementation detail the `groupBy` assertion above it already covers

The narrowing test needs to know exactly what its group holds, and the
`roleAccount` group is shared by every spec in a worker, so this adds an
`isolatedGroupManager` fixture that authenticates into a group of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
…ds filter

The toggle test seeded only recordless subjects, so a filter that hid every
subject unconditionally would have passed it. A third subject now holds an
uploaded record and must be the one row that survives the toggle, pinning
the filter in both directions. The auth-injection init script repeated
between the two authenticating fixtures is shared instead of restated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A group manager's rule compiles to `groupId: { in: [...] }` and prisma's `in`
never matches null, so admin-created records do not make their subject count
as "with records" for a manager. Intended, but invisible at the call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gdevenyi
gdevenyi force-pushed the perf/has-record-filter branch from 3bd9380 to 3a73274 Compare July 28, 2026 22:08
gdevenyi added a commit that referenced this pull request Jul 28, 2026
Which records reach an exported file is decided client-side, from the rows
the master table is currently listing. Nothing covered that: `datahub.spec.ts`
asserted only that the page header renders, so getting the scoping wrong
would have handed the user another subject's data with no test failing.

Seeds two subjects with records, filters the table down to one, exports
JSON and asserts the payload names only that subject. Verified by mutation
-- reading the core row model instead of the pre-pagination one, which
ignores the filter, fails it.

The test needs to know exactly what its group holds, and the `roleAccount`
group is shared by every spec in a worker, so this adds an
`isolatedGroupManager` fixture that authenticates into a group of its own,
plus `uploadRecords` and `findInstrumentIdByName` on the api client for
seeding a subject that has a record at all.

Also from review: the fixture builds a complete `Subject` rather than
casting `{ id }` into one.

Note: #1426 introduces the same `isolatedGroupManager` fixture, so whichever
of the two merges second should drop its copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
@gdevenyi
gdevenyi requested a review from joshunrau July 28, 2026 22:21

@joshunrau joshunrau left a comment

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 branch no longer merges cleanly into main — GitHub reports it as conflicting, so it can't be reviewed or merged as it stands. main has moved since the last pass (#1444 and the e2e-test work landed).

Please merge or rebase onto current main and push. Review picks up again once the branch is mergeable and CI is green — the new commits since the last review haven't been looked at yet.

Reviewed at commit 3a73274.

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.

Performance: 'has records' subject filter reads every instrument record to compute a distinct subject list

3 participants