perf(api): scope the upload response and validate before writing - #1424
perf(api): scope the upload response and validate before writing#1424gdevenyi wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves the scalability and correctness of InstrumentRecordsService.upload() in apps/api by (1) validating all records before any writes, (2) returning only the records created by the upload (instead of all existing records for the group/instrument), and (3) removing an extra DB round-trip in SessionsService.create() by returning the created session with its subject included.
Changes:
- Validate the entire upload batch up front to avoid partial writes when encountering an invalid record mid-batch.
- Scope the upload response to records associated with the newly created session IDs.
- Optimize
SessionsService.create()to return the created session withinclude: { subject: true }in a single query. - Add tests to assert response scoping and “validate-before-write” behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| apps/api/src/sessions/sessions.service.ts | Avoids a second query by returning the created session (with subject included) directly from create. |
| apps/api/src/instrument-records/instrument-records.service.ts | Validates records before writes and returns only records created by the current upload. |
| apps/api/src/instrument-records/tests/instrument-records.service.spec.ts | Adds tests for response scoping and early rejection of invalid batches. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!parseResult.success) { | ||
| console.error(parseResult.error.issues); | ||
| throw new UnprocessableEntityException( | ||
| `Data received for record does not pass validation schema of instrument '${instrument.id}'` | ||
| ); | ||
| } |
| const preProcessedRecords = await Promise.all( | ||
| records.map(async (record) => { | ||
| const { data: rawData, date, subjectId } = record; | ||
|
|
||
| // Validate data | ||
| const parseResult = instrument.validationSchema.safeParse(this.parseJson(rawData)); | ||
| if (!parseResult.success) { | ||
| console.error(parseResult.error.issues); | ||
| throw new UnprocessableEntityException( | ||
| `Data received for record does not pass validation schema of instrument '${instrument.id}'` | ||
| ); | ||
| } | ||
| validatedRecords.map(async (record) => { |
| // returned by the create itself rather than re-read afterwards, which cost a second round trip | ||
| // for every session created |
|
#1427 is an alternative to this PR — it does everything here plus the The short version:
The deciding factor is the That makes #1427 the better change but a larger one, since it alters behaviour for every caller of |
|
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. The Jul 21 re-run reused the original merge commit, so it stayed stale — a fresh push is what's needed, not another re-run.
No content review has been done on this branch yet — it's gated on CI being green. This PR and #1427 remain mutually exclusive; which one survives is worth settling first.
Three changes to the bulk record upload path.
The response returned every record in the group for that instrument
rather than the records the request created:
return this.instrumentRecordModel.findMany({ where: { groupId, instrumentId } });
so the response grew with the size of the database instead of the size
of the request. Measured against a live instance, uploading 25 records
into an instrument that already held 1,200:
before 1,300 records, 871,056 bytes (and larger on every upload)
after 25 records, 14,691 bytes (flat)
Records are now validated up front, before any session is created. A
malformed record in the middle of a batch previously rejected the request
only after sessions had been created for the records ahead of it, which
then had to be rolled back by the catch handler.
`SessionsService.create` created a session and then immediately re-read it
to attach the subject. The create now returns it directly with the same
`include`, removing a round trip per session for every caller -- the
upload path, the gateway synchronizer, the demo seeder and the sessions
controller. Roughly 25 fewer mongodb operations on a 25-record upload,
matching one fewer query per session.
Not addressed here: sessions are still created one per record. Batching
them means reproducing `SessionsService.create`'s subject resolution and
group association inline, including its existing quirk of only setting a
session's groupId when the subject was not already a member of that
group. That belongs in a refactor of the sessions service with its own
review rather than duplicated into the upload path.
Refs #1414
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfen9VQemZanJLXinJ6MMG
Three points from review on this branch: `Promise.all` rejects on the first failure while its siblings are still in flight, so a session created after the catch had begun was missing from `createdSessionsArray` and survived the rollback -- an orphaned session pointing at no record. Settle every task first, then rethrow, so the array is complete before anything is undone. Pinned by a test that fails against `Promise.all`. Validation failure no longer writes the zod issues to stdout, and carries them on the response instead, matching what `create` already does. The message names the offending index, which a batch needs and a single-record create does not. Grammar in the `SessionsService.create` comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc
d6ed3dc to
cba4f8f
Compare
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. From review on this branch: - The rollback no longer spans the response read-back. Deleting the sessions after the insert had succeeded would strand the records that reference them, so only the insert is inside the catch. - Bulk records carry `pending` again. Dropping it was a regression against 573f307 on main, and it is written per instrument kind, as `create` does, so a file record stays pending until its files arrive. - Validation failure carries the zod issues on the response and names the offending index, rather than printing them to stdout. - The group association re-checks membership in the query itself. The subject list is a snapshot, so a concurrent request could otherwise `push` a duplicate group id. `SessionsService` had no test file at all; this adds one, covering the groupId fix, input ordering, batching and the association guard. Refs #1414 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 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 — this is a tight, well-evidenced change, and the three parts of it are each clearly
motivated. The merge result is lint-clean and the whole suite passes (344), including the upload test
your branch reported failing; that one was already reworked on main. The allSettled fix is right,
and pinning it with a test that fails against Promise.all is exactly the right way to land it.
The one decision this was waiting on has been made, and it goes to #1427: it is a superset of this
change and also fixes the session groupId bug, and the group-visibility widening that comes with it
is wanted. So this PR is being closed as superseded rather than merged — not for anything wrong with
it. The conservative case for it was real, and the review of it is the reason #1427's own rework list
is as specific as it is.
A few things from this review are worth carrying into #1427, since they apply there too:
- There is no end-to-end test. Every change needs a unit test and an e2e test in
testing/, and
testing/src/specs/has no upload spec at all. The scoped response is observable through the
/upload/$instrumentIdpage — uploading a batch into an instrument that already holds records is
the natural pin. sessions.service.ts:54changes, butapps/api/src/sessions/has no__tests__directory at
all, so nothing asserts thatcreatestill returns the session with its subject attached. Add
apps/api/src/sessions/__tests__/sessions.service.spec.tscovering that the create carries
include: { subject: true }and that no second read follows it.instrument-records.service.ts:478— the rollback still spans the response read-back.createMany
succeeds at:472, then a failure in thefindManyat:478lands in thecatchat:483and
deletes every session, stranding the records that were just written against sessions that no
longer exist. Move the response read outside thetry, or narrow thecatchto the writes. It is
inherited frommain, but this PR reworks that rollback, and #1427 already fixed it.- Two of the new comments explain what the code used to do rather than what it does:
instrument-records.service.ts:476-477("rather than every record in the group for this
instrument") andsessions.service.ts:72-73("re-reading it afterwards cost a second round trip").
Both commit messages already say that well; comments here are reserved for the non-obvious. The
allSettledcomment at:430-433is the right kind — keep it.
If you hand the #1427 rework to Claude Code, Fable 5 is the right size — it sits on the
session/data-integrity path and spans two services plus a new e2e spec and a new unit-test file.
Reviewed at commit cba4f8f.
PR #1424 — perf(api): scope the upload response and validate before writingCLOSE · confidence: high · gdevenyi · +117/-24, 3 files What it does
Why this verdictNothing here is about the quality of the work — the engineering is good: focused, lint-clean, whole suite green, and the added tests are real (the rollback test was verified by mutation against Settled (2026-07-28)
Action items
Suggested model: Fable 5 — the change sits on the session/data-integrity escalation path, and the action items span two services plus a new e2e spec and a new unit-test file. Evidenceok: worktree on the merge result ( not checked: nothing was run against a real MongoDB — the author's byte counts and mongo-op counts are taken on trust, and Reply to authorThanks — this is a tight, well-evidenced change, and the three parts of it are each clearly The one decision this was waiting on has been made, and it goes to #1427: it is a superset of this A few things from this review are worth carrying into #1427, since they apply there too:
If you hand the #1427 rework to Claude Code, Fable 5 is the right size — it sits on the Reviewed at commit cba4f8f. |
|
Rebased onto All four points from your review are carried into #1427:
Nothing here needs re-reviewing. |
Addresses part of #1414.
1. The response described the database, not the request
uploadreturned every record in the group for that instrument:so the response grew with the size of the database rather than the size of the upload, and the caller (
useUploadInstrumentRecordsMutation) has no use for the pre-existing rows.Measured against a live instance, uploading 25 records into an instrument that already held 1,200:
mainNote the unbounded growth on
main— two successive 25-record uploads returned 1,275 then 1,300 records. The branch returns 25 both times.2. Records are validated before anything is written
A malformed record in the middle of a batch previously rejected the request only after sessions had been created for the records ahead of it, which the
catchhandler then had to roll back. Validation now happens up front, so an invalid batch creates nothing at all.3. One fewer round trip per session, for every caller
SessionsService.createcreated a session and then immediately re-read it to attach the subject:The
createnow returns it directly with the sameinclude. Identical return shape, one less query — and this benefits the upload path, the gateway synchronizer, the demo seeder and the sessions controller alike. Measured ~25 fewer MongoDB operations on a 25-record upload, matching one fewer query per session.What this deliberately does not do
Sessions are still created one per record. I looked at batching them into a single
createManyand decided against it in this PR.SessionsService.createcarries semantics that would have to be reproduced inline inuploadto batch it: find-or-create for each subject, and appending the group to the subject when it isn't already a member. It also has an existing quirk worth knowing about before anyone touches it —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 looks like a bug, and it is why uploaded records showgroupId: nullin practice — but "fix it" and "batch it" are different changes, and doing them together insideuploadwould duplicate the logic and invite drift.Batching belongs in a refactor of
SessionsService(a realcreateManythere, with the group behaviour settled deliberately), reviewed on its own. #1414 stays open for it, as does the transaction wrapper (item 5).Tests
Two added:
groupIdnorinstrumentId;sessionsService.create,subjectsService.createManyorinstrumentRecordModel.createManyis called.The existing upload tests are unchanged and still pass.
Checks
tsc --noEmitonapps/api— cleaneslint src— cleanprettier --check— cleanvitest run(whole workspace) — 254 passed, 1 skipped, 1 failedThe one failure is
upload > should create records with pending set. This branch is cut frommain, so it still carries that pre-existing failure — #1422 fixes it and is green there. The two PRs touch adjacent lines in the same object literal and will need a trivial rebase depending on merge order.Rebased onto
main, review items addressedRebased onto
26ec89168; CI green. Still mutually exclusive with #1427.Copilot:
Promise.allcould orphan a session. Real, and now fixed.allrejects on the firstfailure while its siblings are still in flight, so a session created after the
catchhad begun wasmissing from
createdSessionsArrayand survived the rollback. Switched toallSettled, then rethrow,so every session that exists is recorded before anything is undone. Pinned by a test that fails
against
Promise.all— verified by reverting the change and watching it go red.Copilot: validation logging and dropped issues.
console.error(parseResult.error.issues)is gone.The exception now carries the issues, matching what
createalready does, and the message names theoffending record's index — which a batch needs and a single-record create does not.
Copilot: comment grammar. Fixed.
🤖 Generated with Claude Code
https://claude.ai/code/session_014ZEJLwa7jwD8sKzcE4PfSc