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 1 commit into
Conversation
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
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 |
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.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bfen9VQemZanJLXinJ6MMG