Skip to content

perf(api): batch session creation and scope the upload response (alternative to #1424)#1427

Open
gdevenyi wants to merge 1 commit into
mainfrom
perf/sessions-create-many
Open

perf(api): batch session creation and scope the upload response (alternative to #1424)#1427
gdevenyi wants to merge 1 commit into
mainfrom
perf/sessions-create-many

Conversation

@gdevenyi

Copy link
Copy Markdown
Contributor

Alternative to #1424. Same issue (#1414), but this one takes the SessionsService refactor that #1424 deliberately deferred. Merge one or the other, not both.

What #1424 does, and what this adds

#1424 this PR
Response scoped to the upload
Validate before writing
Remove the re-read in SessionsService.create ✅ (subsumed by the rewrite)
Batch session creation (the N+1) ❌ deferred
Fix the session groupId bug

The refactor

SessionsService.createMany resolves subjects, the user and the group once for the whole batch, associates subjects with the group in a single updateMany, 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. create now delegates to it, so there is one implementation rather than two that can drift.

Measured over HTTP, live instance, uploading 25 records

records returned bytes mongo ops
main 85 → 110 (grows every upload) 51–66 KB ~242–276
this branch 25 (flat) 14.8 KB ~95

For 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

create set a session's groupId only when the subject was not already a member of that group:

let group: Group | null = null;
if (groupId && !subject.groupIds.includes(groupId)) { group = await this.groupsService.findById(groupId);  }
// …
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: 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:

main behaviour  (quirk-subject): set, MISSING
this branch     (fixed-subject): set, set

This is the same class of problem as #1404 ("old data not visible"), and it is why I did not want to batch inside upload in #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 groupId where 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.createMany kept only the id of each entry and discarded the demographics a clinical subject carries:

const subjectsToCreate: CreateSubjectDto[] = subjectsToCreateIds.map((record) => ({ id: record }));

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:

  • every record goes into a single createMany call (25 records → 1 call, 25 entries) — the N+1 guard;
  • each record is paired with its own session by position;
  • the response is scoped to the sessions created;
  • an invalid record rejects before any session is created.

Checks

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

The one failure is upload > should create records with pending set. This branch is cut from main, 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 catch still compensates by deleting them, as before. prismaClient.$transaction would make that atomic; the replica set is already configured for it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bfen9VQemZanJLXinJ6MMG

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

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 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 SessionsService to add createMany, resolving user/group/subjects once per batch and inserting sessions in bulk (avoids per-record N+1 work and fixes the groupId omission bug).
  • Updates InstrumentRecordsService.upload to validate all records up front, create sessions in one batch, insert records in one createMany, and return only records for the created sessions.
  • Adjusts subject bulk creation so createMany preserves provided subject fields (not just id) 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) } }
});
}
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