perf(api): batch session creation and scope the upload response (alternative to #1424)#1427
Open
gdevenyi wants to merge 1 commit into
Open
perf(api): batch session creation and scope the upload response (alternative to #1424)#1427gdevenyi wants to merge 1 commit into
gdevenyi wants to merge 1 commit into
Conversation
An alternative to #1424, which deliberately left the N+1 in place. This takes the refactor #1424 deferred. `SessionsService` gains a `createMany` that resolves subjects, the user and the group once for the whole batch, associates subjects with the group in a single write, and inserts every session in one call. Session ids are generated up front so the results come back in input order and a caller can pair each session with the entry that produced it. `create` now delegates to it, so there is one implementation rather than two. `upload` uses it, and validates every record before anything is written so a malformed record in the middle of a batch no longer creates sessions that then have to be rolled back. Its response is scoped to the sessions this upload created rather than returning every record in the group for that instrument. Measured over HTTP against a live instance, uploading 25 records: main 85-110 records returned, 51-66 KB, ~242-276 mongo ops this branch 25 records returned, 14.8 KB, ~95 mongo ops The record count on main grows on every upload; here it stays at 25. Fixes a bug along the way. `create` set a session's groupId only when the subject was not already a member of that group: if (groupId && !subject.groupIds.includes(groupId)) { group = ... } // ... group: group ? { connect: { id: group.id } } : undefined, so every visit after a subject's first produced a session with no groupId. A GROUP_MANAGER's Session rule is { groupId: { in: [...] } }, which means those sessions were invisible to them, and uncounted by any group-scoped query including the dashboard summary. Verified on a live instance: two sessions for one subject in one group produced groupId "set, MISSING" on main and "set, set" here. `SubjectsService.createMany` also kept only the id of each entry, discarding the demographics a clinical subject carries. That was harmless while its only caller passed ids alone, but the batched session path resolves subjects through it, so it now persists what it is given. Refs #1414 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bfen9VQemZanJLXinJ6MMG
Contributor
There was a problem hiding this comment.
Pull request overview
This PR is an alternative implementation to #1424 for addressing #1414 in apps/api, focusing on making bulk instrument record upload scale better and behave more predictably by batching session creation, scoping the upload response to the request, and fixing session groupId assignment so group-scoped visibility/counts are correct.
Changes:
- Refactors
SessionsServiceto addcreateMany, resolving user/group/subjects once per batch and inserting sessions in bulk (avoids per-record N+1 work and fixes thegroupIdomission bug). - Updates
InstrumentRecordsService.uploadto validate all records up front, create sessions in one batch, insert records in onecreateMany, and return only records for the created sessions. - Adjusts subject bulk creation so
createManypreserves provided subject fields (not justid) while de-duping within-request duplicates.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| apps/api/src/subjects/subjects.service.ts | Preserve full CreateSubjectDto entries during bulk subject creation instead of dropping non-id fields; de-dupe via map keyed by id. |
| apps/api/src/sessions/sessions.service.ts | Adds batched createMany for sessions (pre-generated ids + readback in input order) and ensures groupId is consistently set. |
| apps/api/src/instrument-records/instrument-records.service.ts | Upload now validates before writing, batches session creation, scopes response to created sessions, and reduces DB round-trips. |
| apps/api/src/instrument-records/tests/instrument-records.service.spec.ts | Updates upload unit tests to assert batching semantics, positional pairing, scoped response, and “validate before write” behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
417
to
421
| return this.instrumentRecordModel.findMany({ | ||
| where: { | ||
| groupId, | ||
| instrumentId | ||
| sessionId: { in: sessions.map((session) => session.id) } | ||
| } | ||
| }); |
Comment on lines
+379
to
+384
| if (!parseResult.success) { | ||
| console.error(parseResult.error.issues); | ||
| throw new UnprocessableEntityException( | ||
| `Data received for record does not pass validation schema of instrument '${instrument.id}'` | ||
| ); | ||
| } |
Comment on lines
+70
to
76
| const unassociated = subjects.filter((subject) => !subject.groupIds.includes(group.id)); | ||
| if (unassociated.length > 0) { | ||
| await this.prismaClient.subject.updateMany({ | ||
| data: { groupIds: { push: group.id } }, | ||
| where: { id: { in: unassociated.map((subject) => subject.id) } } | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Alternative to #1424. Same issue (#1414), but this one takes the
SessionsServicerefactor that #1424 deliberately deferred. Merge one or the other, not both.What #1424 does, and what this adds
SessionsService.creategroupIdbugThe refactor
SessionsService.createManyresolves subjects, the user and the group once for the whole batch, associates subjects with the group in a singleupdateMany, and inserts every session in one call. Session ids are generated up front so results come back in input order and a caller can pair each session with the entry that produced it by index.createnow delegates to it, so there is one implementation rather than two that can drift.Measured over HTTP, live instance, uploading 25 records
mainFor reference, #1424 measured ~216 ops on the same workload — it removed one query per session but kept the per-record loop.
Correctness verified on the same run: 25 records → 25 distinct sessions, every record paired with the session holding its own subject.
The bug this fixes
createset a session'sgroupIdonly when the subject was not already a member of that group:So every visit after a subject's first produced a session with no
groupId. AGROUP_MANAGER's Session rule is{ groupId: { in: groupIds } }— those sessions were invisible to them, and uncounted by every group-scoped query including the dashboard summary.Reproduced on a live instance, two sessions for one subject in one group:
This is the same class of problem as #1404 ("old data not visible"), and it is why I did not want to batch inside
uploadin #1424 — doing so would have duplicated the quirk into a second place rather than settling it.This is a behaviour change, and it is the reason to review this PR carefully. Sessions created from now on will carry a
groupIdwhere they previously would not. Nothing becomes less visible; some previously-hidden sessions become visible to group managers and appear in group-scoped counts. Existing rows are untouched — a backfill for historical sessions would be a separate migration, and is worth considering.One more thing found on the way
SubjectsService.createManykept only theidof each entry and discarded the demographics a clinical subject carries:Harmless while its only caller passed ids alone, but the batched session path resolves subjects through it, so it now persists what it is given.
Tests
The existing upload tests are updated to the batched call and keep their assertions. Added:
createManycall (25 records → 1 call, 25 entries) — the N+1 guard;Checks
tsc --noEmitonapps/api— cleaneslint src— cleanprettier --check— cleanvitest run(whole workspace) — 256 passed, 1 skipped, 1 failedThe one failure is
upload > should create records with pending set. This branch is cut frommain, so it carries that pre-existing failure; #1422 fixes it. These two PRs touch adjacent lines and will need a trivial rebase depending on merge order.Still not done
The transaction wrapper (item 5 of #1414). Sessions are now created in one call and records in another, so a failure between them still leaves orphaned sessions — the
catchstill compensates by deleting them, as before.prismaClient.$transactionwould make that atomic; the replica set is already configured for it.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bfen9VQemZanJLXinJ6MMG