perf(api): group the has-record subject ids in the database - #1426
perf(api): group the has-record subject ids in the database#1426gdevenyi wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
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: [...] })withinstrumentRecord.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
groupByis used andabilityis 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.
|
CI is red on this branch, so I have not reviewed it yet. Failing check: That test is not in your diff and no longer exists on Please merge or rebase onto current |
|
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 |
joshunrau
left a comment
There was a problem hiding this comment.
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.
3cf2cba to
53c8db8
Compare
joshunrau
left a comment
There was a problem hiding this comment.
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:
-
accessibleQuery(ability, 'read', 'InstrumentRecord')atapps/api/src/subjects/subjects.service.ts:178
throws when the caller holds noreadrule forInstrumentRecord.@casl/prisma's
accessibleByproxy raisesForbiddenErrorrather than returning{}in that case, and every
STANDARD user is in that case —ability.factory.tsgrants themcreateonInstrumentRecordbut
neverread. 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
anHttpException, so libnest's filter maps it to Internal Server Error). Onmainthe same
request returns a correct 200. I reproduced this by drivingSubjectsService.find({ hasRecord: true })
with an ability built fromAbilityFactory.createForPayload({ basePermissionLevel: 'STANDARD', ... }).Nobody hits it through the web UI —
useNavItems.ts:63hides the datahub entirely without
read InstrumentRecord— butGET /v1/subjects?hasRecord=trueis a documented endpoint and the
guard admits these callers. Every otheraccessibleQuery(..., '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 beforeaccessibleQueryis reached,
or theForbiddenErrorcatching, with an empty result returned either way. -
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 readInstrumentRecord, so it exercises the
conditioned branch but never the no-rule branch — which is exactly the one that breaks. -
There is no e2e test, which the root
CLAUDE.mdasks 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:169has nodata-testidand
testing/src/pages/_app/datahub/index.page.tsexposes no filter locator, so both need adding. -
Minor:
expect(prismaClient.instrumentRecord.findMany).not.toHaveBeenCalled()in
subjects.service.spec.ts:105asserts an implementation detail. ThegroupByassertion just above
it already stops thedistinctform 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.
53c8db8 to
f68c7ef
Compare
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
joshunrau
left a comment
There was a problem hiding this comment.
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:
apps/api/src/subjects/subjects.service.ts:182-187— please note in the doc comment that the
accessibleQueryclause drops records with a nullgroupId, since a group manager's rule matches
groupId: { in: groupIds }and Prisma'sindoes 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.
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>
3bd9380 to
3a73274
Compare
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
joshunrau
left a comment
There was a problem hiding this comment.
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.
Fixes #1413.
The "with records only" datahub filter resolved its subject list with:
Prisma applies
distinctin 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.groupByasks MongoDB for the distinct values directly.Measured over HTTP, live instance, 200,300 records, median of 7
mainGET /v1/subjects?hasRecord=trueGET /v1/subjects?groupId=<g>&hasRecord=trueGET /v1/subjects?groupId=<g>(filter off, for reference)The unscoped case is where the difference shows, and it widens with the collection:
groupByreturns 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:
Note
distinctgoing 197 → 1229 ms as the scope widens whilegroupBystays 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:
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:
Prisma compiles a relation filter on MongoDB into a
$lookupover 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
querySubjectIdsWithRecordsignoredabilityentirely. 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
hasRecordtests are updated to the new call and still assert the same outcomes. Two added:groupByis used andfindManyis not — a guard against thedistinctform coming back;Checks
tsc --noEmitonapps/api— cleaneslint src— cleanprettier --check— cleanvitest run(whole workspace) — 254 passed, 1 skipped, 1 failedThe one failure is
instrument-records.service.spec.ts > upload > should create records with pending set. This branch is cut frommainso it carries that pre-existing failure; #1422 fixes it.Rebased onto
main, review item addressedRebased onto
26ec89168; CI green.Copilot:
expect(call.where.AND[0]).not.toStrictEqual({})did not prove anything. Correct, andworse than it looks — an unconditional
readrule legitimately produces{}, so that assertion wouldhave 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
accessibleQueryimported as suggested.Verified by mutation: passing
undefinedin place of the ability now fails the test, where before itpassed.
Round 2: rebased onto
5bfe4b2dc, review items addressed1. The 500 — confirmed and fixed. Reproduced exactly as you described: with a STANDARD ability,
accessibleQuery(ability, 'read', 'InstrumentRecord')throwsForbiddenError: It's not allowed to run "read" on "InstrumentRecord"rather than returning{}.querySubjectIdsWithRecordsnow returns an empty list for a caller holding no such rule, beforeaccessibleQueryis 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
AbilityFactorywith a STANDARD payload. Verified by mutation: removing theguard fails it with that
ForbiddenError.3. End-to-end.
datahub.spec.tsnow 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-recordsanddatahub-filters-triggertestids andfilter locators you flagged as missing.
The narrowing test has to know exactly what its group holds, and the
roleAccountgroup is shared byevery spec in a worker, so this adds an
isolatedGroupManagerfixture. #1425 introduces the samefixture — whichever merges second should drop its copy.
4. The
not.toHaveBeenCalled()assertion. Dropped, agreed — thegroupByassertion above italready rules out the
distinctform.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