Repository navigation
Performance: bulk record upload is N+1 on sessions and returns every record in the group #1414
Description
Current Implementation and Limitations
InstrumentRecordsService.upload() (apps/api/src/instrument-records/instrument-records.service.ts:352) handles bulk record upload — the endpoint whose entire purpose is to ingest many records at once. It currently performs a per-record chain of database calls and then returns far more data than it created.
Per-record session creation (:388-425). Every uploaded record gets its own sessionsService.create(...) call inside a Promise.all(records.map(...)). Each SessionsService.create (sessions.service.ts:31) is itself 3–5 round trips:
resolveSubject→subjectsService.findById(andcreateon miss)prismaClient.user.findFirst({ where: { username } })— the same username, re-queried once per record- possibly
groupsService.findById+subjectsService.addGroupForSubject sessionModel.createsessionModel.findUniqueto re-read the row that was just created
So uploading N records issues roughly 4N–5N queries. The Promise.all makes them concurrent rather than sequential, which helps latency but means N × 5 queries are fired at the connection pool simultaneously — with a large upload this saturates the pool and starves every other in-flight request.
Redundant user lookup. username is a single value for the whole upload and is already validated once at :368 (usersService.findByUsername). Looking it up again inside every SessionsService.create is pure waste.
The return value is wrong-sized (:430-435). After createMany, the method returns:
return this.instrumentRecordModel.findMany({
where: { groupId, instrumentId }
});This is every record for that group and instrument — not the records that were just uploaded. Upload 10 records into a group that already holds 25,000 for that instrument and the response contains all 25,010, full data and computedMeasures included. The response grows with the size of the database rather than the size of the request, and the caller (useUploadInstrumentRecordsMutation) has no use for the pre-existing rows.
Error handling amplifies it. The catch at :436 calls sessionsService.deleteByIds(...), which is fine, but the failure path is reached only after all N sessions have been created — so a validation failure on the last record still costs the full N × 5 queries plus the cleanup.
Associated Application Components
Server
Proposed Solution
1. Hoist the invariants out of the loop. username → user, groupId → group, and the subject set are all resolved once for the whole upload. subjectsService.createMany is already called at :386 for exactly this reason; the session path should follow the same pattern.
2. Validate before writing anything. Run the instrument.validationSchema.safeParse pass over all records first, and only start creating sessions once every record is known to be valid. This removes the create-then-roll-back path in the common failure case.
3. Batch the session creation. Build the session rows in memory and insert them with a single createMany, then map record → sessionId locally:
const sessionRows = records.map((record) => ({
date: record.date,
groupId: groupId ?? null,
subjectId: record.subjectId,
type: 'RETROSPECTIVE' as const,
userId: user?.id ?? null
}));
await this.sessionModel.createMany({ data: sessionRows });Since MongoDB createMany does not return the inserted ids, either pre-generate the ObjectIds application-side (as AssignmentsService.create already does with crypto.randomUUID()) or re-query once with a single findMany. Either way this is O(1) round trips instead of O(N).
4. Return only what was created. Replace the findMany({ groupId, instrumentId }) with the ids that were just inserted — or, since the client does not appear to need the bodies, just a count. This is the single highest-impact change in the function and it is small.
5. Wrap the whole thing in a transaction. The current manual compensation (deleteByIds in the catch) is best-effort and leaves orphaned subjects behind if it fails. prismaClient.$transaction is already used in SubjectsService.deleteById, and the replica set is configured for it (MONGO_REPLICA_SET=rs0 in docker-compose.yaml).
Estimated Difficulty
Medium
Priority
Medium
Activity
joshunrau commentedon Jul 20, 2026
@gdevenyi Claude can do this
gdevenyi commentedon Jul 20, 2026
#1424 covers the response scoping (item 4), up-front validation (item 2) and removes a redundant re-read in
SessionsService.create. Items 1, 3 and 5 are still open — sessions are still created one per record.Worth recording why I didn't batch them there. Doing so means reproducing
SessionsService.create's subject find-or-create and group association inline inupload, and that method has a quirk that should be settled first:groupis only non-null when the subject was not already in the group, so a session created for an existing group member is persisted withgroupId: null. That appears to be a bug in its own right, and it explains why uploaded records showgroupId: null. Batching properly probably means adding a realcreateManytoSessionsServiceand deciding that behaviour deliberately, rather than duplicating the current logic into the upload path.perf(api): scope the upload response and validate before writing
perf(api): batch session creation and scope the upload response
perf(api): batch session creation and scope the upload response
perf(api): scope the upload response and validate before writing
perf(api): batch session creation and scope the upload response
perf(api): batch session creation and scope the upload response
perf(api): batch session creation and scope the upload response
joshunrau commentedon Oct 26, 2026
Partially fixed on
main(d86740e). Items 1-4 landed in #1427 (24e3775, merged 2026-09-23) and #1556 (49fd5a9). ThegroupIdquirk from the comment above is fixed too:SessionsService.createManyresolves the group whenevergroupIdis set and stamps it on every session (apps/api/src/sessions/sessions.service.ts:68,:88), pinned byapps/api/src/sessions/__tests__/sessions.service.test.ts:61andtesting/src/specs/sessions.spec.ts:14. Only item 5 is open.What is left
deleteByIdssucceeds. The catch atapps/api/src/instrument-records/instrument-records.service.ts:561-564deletes sessions only. A failed record insert keeps the subjects created atsessions.service.ts:73and the group pushed onto existing subjects at:74-79. A rejected upload still enrols its subjects in the group.computeMeasuresruns inside the try at:550. Or the database can fail, for example on a dropped connection or a record over MongoDB's 16 MiB document limit. The 50 MiB body limit lets such a record through (libnest 9.0.0src/app/app.container.ts:31).Plan
Use the array form of
$transaction, whichsubjects.service.ts:146andinstrument-records.service.ts:169already use. Session ids are generated before the insert (sessions.service.ts:83), so no write needs another write's result. The reads (user, group, existing subjects) can run before the transaction. The interactive form would need atxclient passed through three services, because libnest's injected models are bound to the root client (libnestsrc/modules/prisma/prisma.module.ts:15-16), and its 5 s defaulttimeoutraised.SubjectsService: splitcreateManyinto an awaited existence read and a returned write. DropasyncfromaddGroupForSubjectsso it returns theupdateManyitself. Anasyncfunction that returns aPrismaPromiseruns the query right away.$transactionthen throws "All elements of the array need to be Prisma Client promises".SessionsService: inject the client with@InjectPrismaClient(). Add aprepareCreateManythat returns{ ids, writes }, and havecreateManyrun$transaction([...writes, readBack]). A failed read-back query then rolls back with the writes, so the try/catch at:96-102can go.deleteByIdsis then left with no caller and no test, so delete it.upload: callprepareCreateMany, build the record rows, then run$transaction([...writes, instrumentRecordModel.createMany(...)])keyed onids[index]. Delete the try/catch at:546-564. The measures are computed before the transaction starts, so a throwing measure writes nothing. Upload only uses the session ids (:557,:571), so it can skip the session read-back and itsinclude: { subject: true }.create()delegates tocreateMany, sosessions.controller.ts:20,gateway.synchronizer.ts:159anddemo.service.ts:128also get atomic subject and session writes. The synchronizer's cleanup atgateway.synchronizer.ts:217-225is not covered, because its try also spans the gateway calldeleteRemoteAssignment(:216). That is a separate change, and the fix for #1637 touches the same method.Gotchas
transactionLifetimeLimitSeconds, 60 s by default. Time an upload near the body limit against the docker-compose replica set.addGroupForSubjectsfilters onNOT: { groupIds: { has: groupId } }(subjects.service.ts:53). Inside transactions, MongoDB aborts one of them with a write conflict. Return that as an error. Nothing was written, so the client can safely retry.$transactioncalls to the two inapps/api, and Upgrade to Prisma 7 (blocked on libnest, esbuild-plugin-prisma and MongoDB support) #1625 would have to port all four.Tests
apps/api/src/sessions/__tests__/sessions.service.test.ts, replace the test at:202. The new test asserts that the subject, group and session writes and the read-back go to one$transactionin that order, likeapps/api/src/subjects/__tests__/subjects.service.test.ts:296. Inapps/api/src/instrument-records/__tests__/instrument-records.service.test.ts, replace:566and:574with one test: the prepared writes and then the record insert go to one$transaction. Add a second test that a throwing measure starts no transaction. Mocked Prisma cannot show a rollback.'should write nothing when the record insert fails'totesting/src/specs/upload.spec.ts. Upload to a new group with two records. One is for a new subject. The other is for a subject made withapi.createSubject(otherGroup.id)and carries areasonNotSatisfiedover 16 MiB (the field is an unboundedz.string()). Expect a non-2xx response. Expect a 404 fromGET /api/v1/subjects/<new id>, as the FILE instrument test at:89does. Expect the existing subject'sgroupIdsto still be[otherGroup.id]. Confirm the test fails onmainfirst.#1660 rewords the comment at
instrument-records.service.ts:568, and #1825 appends adescribeblock toupload.spec.ts. Both are trivial rebases.Triage note written with Claude Code. Code references point at
main@d86740e.