Skip to content

perf(api): scope the upload response and validate before writing#1424

Open
gdevenyi wants to merge 1 commit into
mainfrom
fix/upload-batching
Open

perf(api): scope the upload response and validate before writing#1424
gdevenyi wants to merge 1 commit into
mainfrom
fix/upload-batching

Conversation

@gdevenyi

Copy link
Copy Markdown
Contributor

Addresses part of #1414.

1. The response described the database, not the request

upload returned every record in the group for that instrument:

return this.instrumentRecordModel.findMany({ where: { groupId, instrumentId } });

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:

records returned bytes
main 1,300 871,056
this branch 25 14,691

Note 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 catch handler 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.create created a session and then immediately re-read it to attach the subject:

const { id } = await this.sessionModel.create({ data: {} });
return (await this.sessionModel.findUnique({ include: { subject: true }, where: { id } }))!;

The create now returns it directly with the same include. 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 createMany and decided against it in this PR.

SessionsService.create carries semantics that would have to be reproduced inline in upload to 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 —

let group: Group | null = null;
if (groupId && !subject.groupIds.includes(groupId)) { group = await this.groupsService.findById(groupId);  }
// …
group: group ? { connect: { id: group.id } } : undefined,

group is only non-null when the subject was not already in the group, so a session created for an existing group member is persisted with groupId: null. That looks like a bug, and it is why uploaded records show groupId: null in practice — but "fix it" and "batch it" are different changes, and doing them together inside upload would duplicate the logic and invite drift.

Batching belongs in a refactor of SessionsService (a real createMany there, 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:

  • the response is scoped to the sessions this upload created, and the query carries neither groupId nor instrumentId;
  • an invalid record rejects before sessionsService.create, subjectsService.createMany or instrumentRecordModel.createMany is called.

The existing upload tests are unchanged and still pass.

Checks

  • tsc --noEmit on apps/api — clean
  • eslint src — clean
  • prettier --check — clean
  • vitest run (whole workspace) — 254 passed, 1 skipped, 1 failed

The one failure is upload > should create records with pending set. This branch is cut from main, 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

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with include: { 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.

Comment on lines +381 to +386
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 401 to +402
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) => {
Comment on lines +72 to +73
// returned by the create itself rather than re-read afterwards, which cost a second round trip
// for every session created
@gdevenyi

Copy link
Copy Markdown
Contributor Author

#1427 is an alternative to this PR — it does everything here plus the SessionsService refactor I deferred, so the two are mutually exclusive. Merge one.

The short version:

this PR (#1424) #1427
Response scoped to the upload
Validate before writing
Batch session creation (the N+1) ❌ deferred ✅ ~242–276 → ~95 mongo ops
Fix the session groupId bug

The deciding factor is the groupId quirk I described above. #1427 settles it rather than working around it: create currently sets a session's groupId only when the subject was not already a group member, so every visit after a subject's first produced a session invisible to group managers and uncounted by group-scoped queries. Reproduced live — set, MISSING on main vs set, set on #1427.

That makes #1427 the better change but a larger one, since it alters behaviour for every caller of SessionsService.create. This PR is the conservative option if you would rather take the safe wins now and schedule the refactor separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants