Skip to content

Gateway sends every outstanding assignment's full instrument bundle to the API on each sync pass, only for the API to discard it #1733

Open

Description

@joshunrau

On every sync pass, GET /api/assignments returns every stored assignment with all of its columns. That includes targetStringified, the full compiled instrument bundle (every item's bundle, for a series), and rawPublicKey. The API throws both away. GatewayService.fetchRemoteAssignments parses the body with $RemoteAssignment, which has neither field, and GatewaySynchronizer then skips every OUTSTANDING row without reading anything else. The API calls this endpoint every GATEWAY_REFRESH_INTERVAL (10 s by default), so the gateway serializes, sends and the API parses one copy of the bundle per outstanding assignment, continuously, only to discard it. POST /api/assignments/bulk writes the bundle into every row it creates, so the payload grows with the number of subjects, not the number of instruments. At large enough scale this stops the sync altogether. Once the JSON body passes V8's maximum string length (536,870,888 characters on Node 24), res.json throws RangeError: Invalid string length. The endpoint then returns 500, and the synchronizer logs "Failed to Fetch Remote Assignments" and gives up the pass. Because the failure recurs on every pass, no completed assignment is imported until enough outstanding ones are removed.

Where

apps/gateway/src/routers/api.router.ts:25-38:

router.get(
  '/assignments',
  ah(async (_, res) => {
    const assignments = await prisma.remoteAssignmentModel.findMany();
    return res.status(200).json(
      assignments.map((assignment) => {
        return {
          ...assignment,
          status: assignment.status as AssignmentStatus
        };
      }) satisfies RemoteAssignment[]
    );
  })
);

The consumer, apps/api/src/gateway/gateway.service.ts:97-114 and gateway.synchronizer.ts:83-89, keeps only the $RemoteAssignment fields and continues on OUTSTANDING.

Reproduce

Mechanism and scale: the size of each row is dominated by its bundle. The built-in Enhanced Demographics Questionnaire serializes to about 19 KB per row, of which the fields the API keeps are about 400 bytes; the bundle is escaped twice, once in the column and again by res.json. One bulk operation (BULK_ASSIGNMENT_MAX_SUBJECTS = 500) therefore adds about 9.4 MB that the gateway sends and the API parses every 10 s while those assignments are outstanding, about 0.9 MB/s of steady waste for a single form. The instrument bundler inlines .png, .mp3 and .mp4 assets as data URLs, so an interactive task with media can easily be 1 MB. Roughly 540 outstanding assignments of such a task, across one or two bulk operations, push the response past the 512 MiB string limit, and from then on every sync pass fails.

  1. Bulk-assign an instrument to many subjects and leave the assignments outstanding.
  2. Watch the gateway's GET /api/assignments response size and the API's sync duration on each pass.

Actual: the response grows by one full bundle per outstanding assignment, and past the string limit it becomes a 500 that blocks every import.
Expected: the response carries only the fields $RemoteAssignment defines, a few hundred bytes per row, however large the instruments are.

Tests

  • apps/gateway/src/routers/__tests__/api.router.test.ts: it('should list assignments without their instrument bundles or public keys, so a sync pass does not transfer every bundle'), asserting that findMany is called with an omit (or select) of targetStringified and rawPublicKey, and that the response rows lack them.
  • e2e, testing/src/specs/gateway-assignment.spec.ts: seed an assignment, call GET ${gatewayURL}/api/assignments with the gateway API key (expose GATEWAY_API_KEY from testing/src/support/env.ts), and assert that the seeded row has no targetStringified.

Suggested fix

Pass omit: { rawPublicKey: true, targetStringified: true } (or an explicit select of the $RemoteAssignment fields) to findMany. A further option is to agree with the API on a where: { status: { not: 'OUTSTANDING' } } filter, since the synchronizer ignores outstanding rows. That changes what the endpoint means, though, and would need #1703's expiry handling to read expiresAt some other way.

Activity

  1. added
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Gatewayapps/gateway (patient-facing app for remote assignments)
    PerformanceSpeed, memory or scalability
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). The handler at api.router.ts:25-38 last changed in 3cb429e, before this was filed, and no open PR touches the file.

    Use a typed select, not omit

    rawPublicKey is not small. Prisma 6.19.3 decodes Bytes into a plain Uint8Array, which has no toJSON, so res.json writes the 65-byte key as {"0":4,"1":...}: about 550 bytes, more than the ~400 bytes the API keeps.

    The satisfies RemoteAssignment[] at api.router.ts:35 cannot catch extra columns. It checks the array map returns, not an object literal, so no excess-property check runs. A select typed against the schema does catch them:

    const remoteAssignmentSelect = {
      completedAt: true,
      createdAt: true,
      encryptedData: true,
      expiresAt: true,
      groupId: true,
      id: true,
      status: true,
      subjectId: true,
      symmetricKey: true,
      url: true
    } satisfies { [TKey in keyof RemoteAssignment]-?: true };

    A missing or undeclared key is then a type error, and a column added to RemoteAssignmentModel later stays off the wire. omit is a denylist, and omit: { rawPublicKey: true } still reads the column: getPublicKey in apps/gateway/src/lib/prisma.ts lists it in needs, so Prisma drops it from the query's omit and strips it from the result afterwards.

    Leave out the status: { not: 'OUTSTANDING' } filter. With the select an outstanding row is a few hundred bytes, and the fix suggested in #1703 needs those rows.

    Tests

    • api.router.test.ts mocks @/lib/prisma, so asserting that response rows lack the columns proves nothing; the rows are whatever the test hands the mock. Assert the query. The case at lines 150-155 expects findMany toHaveBeenCalledWith() and will fail; change it to expect a select whose keys equal $RemoteAssignment.keyof().options.
    • e2e: seedAssignment(api) (gateway-assignment.spec.ts:53) creates an outstanding row the synchronizer leaves alone, and apiRequestContext already calls the gateway by absolute URL (line 162). Find the seeded row by id and assert its sorted keys equal Object.keys($RemoteAssignment.shape).sort(), which also covers rawPublicKey and future columns. GATEWAY_API_KEY is in every generated .env (scripts/generate-env.sh, which CI runs), so required('GATEWAY_API_KEY') in testing/src/support/env.ts is safe.

    Out of scope

    The other handlers in api.router.ts also load the bundle without using it (findFirst at :106 and :162, and the rows create, update and delete return), but once per request, so they can follow separately. #1729 is independent and still needed for the server-rendered page.

    Triage note written with Claude Code. Code references point at main @ d86740e.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Gatewayapps/gateway (patient-facing app for remote assignments)Difficulty: LowIsolated change, about 2 hours or lessEnhancementType: an improvement to an existing feature, including performancePerformanceSpeed, memory or scalabilityPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions