Current Implementation and Limitations
The datahub's "With records only" filter is implemented by reading every instrument record in scope into the API process, deduplicating subject ids in JavaScript, and then sending them all back to MongoDB as an $in array.
SubjectsService.querySubjectIdsWithRecords (apps/api/src/subjects/subjects.service.ts:161):
private async querySubjectIdsWithRecords(groupId?: string): Promise<string[]> {
const records = await this.prismaClient.instrumentRecord.findMany({
distinct: ['subjectId'],
select: { subjectId: true },
where: groupId ? { groupId } : {}
});
return records.map((r) => r.subjectId);
}
consumed at :139:
return this.subjectModel.findMany({
where: {
AND: [
accessibleQuery(ability, 'read', 'Subject'),
groupInput,
{ id: { in: await this.querySubjectIdsWithRecords(groupId) } }
]
}
});
Two problems:
distinct is not pushed into MongoDB. Prisma applies distinct in the query engine, not as a server-side $group/distinct command, so the driver receives one document per record, not per distinct subject. The deduplication to a much smaller set happens only after all of them have crossed the wire.
- The result is round-tripped back as a literal
$in array. Every subject id that has any record is serialised into the second query. On a large group this is a query document containing thousands of strings.
Also note where: groupId ? { groupId } : {} ignores the caller's ability entirely — for the unscoped (root) case this reads the whole InstrumentRecordModel collection, regardless of what the requesting user is permitted to see.
This is the issue previously raised as #1346 ("Improve initial hasRecord filter performance", closed). The current implementation still has the shape described above.
Measurements
MongoDB 8, 200,000 instrument records across 8 groups, 5,000 subjects:
scope docs examined docs returned to Prisma distinct subjects time
group 200,000 25,000 625 62 ms (no index)
group 25,000 25,000 625 42 ms (with index)
root (all) 200,000 200,000 5,000 ~85 ms (no index)
So for the group case, Prisma transfers 25,000 documents to compute a 625-element result, then issues a second query whose where clause embeds those 625 ids. For the unscoped case it transfers 200,000 documents to compute 5,000 ids. The ratio of transferred documents to useful output is 40:1 and grows with records-per-subject.
Indexing {groupId, date} (see the indexing issue) fixes the scan but not the transfer — the 25,000 documents still cross the wire.
Associated Application Components
Server
Proposed Solution
1. Do the distinct in the database. A $group aggregation returns one document per subject instead of one per record:
private async querySubjectIdsWithRecords(groupId?: string, ability?: AppAbility): Promise<string[]> {
const result = await this.prismaClient.instrumentRecord.aggregateRaw({
pipeline: [
{ $match: groupId ? { groupId: { $oid: groupId } } : {} },
{ $group: { _id: '$subjectId' } }
]
});
return (result as unknown as { _id: string }[]).map((r) => r._id);
}
With the {groupId, date} index this is a covered-ish scan and returns 625 documents instead of 25,000 — a 40× reduction in data transferred, for the same result.
2. Better still, avoid the round trip entirely. Prisma can express "subject has at least one record" as a relation filter, so the two queries collapse into one and no id list is ever materialised in the application:
return this.subjectModel.findMany({
where: {
AND: [
accessibleQuery(ability, 'read', 'Subject'),
groupInput,
{ instrumentRecords: { some: groupId ? { groupId } : {} } }
]
}
});
This is the version worth trying first — it is simpler than the current code, not more complex. It is worth explain()-ing on a realistic dataset before committing, since MongoDB relation filters in Prisma can compile to a $lookup, and the aggregation in (1) is the fallback if the plan is worse.
3. Respect the ability. querySubjectIdsWithRecords should apply accessibleQuery(ability, 'read', 'InstrumentRecord') to its own where, matching every other query in the service. Currently a request that resolves to the unscoped branch reads records the user has no permission to read (their ids are then intersected away by the outer query, so this is not a data leak, but it is unnecessary work and an inconsistency).
Estimated Difficulty
Medium
Priority
Medium
Current Implementation and Limitations
The datahub's "With records only" filter is implemented by reading every instrument record in scope into the API process, deduplicating subject ids in JavaScript, and then sending them all back to MongoDB as an
$inarray.SubjectsService.querySubjectIdsWithRecords(apps/api/src/subjects/subjects.service.ts:161):consumed at
:139:Two problems:
distinctis not pushed into MongoDB. Prisma appliesdistinctin the query engine, not as a server-side$group/distinctcommand, so the driver receives one document per record, not per distinct subject. The deduplication to a much smaller set happens only after all of them have crossed the wire.$inarray. Every subject id that has any record is serialised into the second query. On a large group this is a query document containing thousands of strings.Also note
where: groupId ? { groupId } : {}ignores the caller'sabilityentirely — for the unscoped (root) case this reads the wholeInstrumentRecordModelcollection, regardless of what the requesting user is permitted to see.This is the issue previously raised as #1346 ("Improve initial hasRecord filter performance", closed). The current implementation still has the shape described above.
Measurements
MongoDB 8, 200,000 instrument records across 8 groups, 5,000 subjects:
So for the group case, Prisma transfers 25,000 documents to compute a 625-element result, then issues a second query whose
whereclause embeds those 625 ids. For the unscoped case it transfers 200,000 documents to compute 5,000 ids. The ratio of transferred documents to useful output is 40:1 and grows with records-per-subject.Indexing
{groupId, date}(see the indexing issue) fixes the scan but not the transfer — the 25,000 documents still cross the wire.Associated Application Components
Server
Proposed Solution
1. Do the distinct in the database. A
$groupaggregation returns one document per subject instead of one per record:With the
{groupId, date}index this is a covered-ish scan and returns 625 documents instead of 25,000 — a 40× reduction in data transferred, for the same result.2. Better still, avoid the round trip entirely. Prisma can express "subject has at least one record" as a relation filter, so the two queries collapse into one and no id list is ever materialised in the application:
This is the version worth trying first — it is simpler than the current code, not more complex. It is worth
explain()-ing on a realistic dataset before committing, since MongoDB relation filters in Prisma can compile to a$lookup, and the aggregation in (1) is the fallback if the plan is worse.3. Respect the ability.
querySubjectIdsWithRecordsshould applyaccessibleQuery(ability, 'read', 'InstrumentRecord')to its ownwhere, matching every other query in the service. Currently a request that resolves to the unscoped branch reads records the user has no permission to read (their ids are then intersected away by the outer query, so this is not a data leak, but it is unnecessary work and an inconsistency).Estimated Difficulty
Medium
Priority
Medium