Skip to content

Retrying a failed file-instrument upload creates a second record and leaves the first pending forever #1713

Open

Description

@joshunrau

Retrying a failed file-instrument upload creates a new instrument record and abandons the previous one. The record page's handleSubmit always starts with POST /v1/instrument-records, which the API stores with pending: true for a FILE instrument. Only after that does it request presigned URLs, PUT each file, and call upload-complete. If any of the later steps throws (a dropped connection mid-PUT, a storage error, a failing upload-complete), FileInstrumentContent shows "Something went wrong" and enables Submit again. Pressing it runs handleSubmit from the top, so it creates a second record. The first record stays pending: true forever: nothing finishes it or cleans it up, and any files it already uploaded stay in storage. Datahub lists hide pending records (find and summarizeBySubject filter them out), but InstrumentRecordsService.count() does not, so each abandoned attempt still inflates the dashboard's "Total Records" and its trend.

Where

apps/web/src/routes/_app/instruments/render/$id.tsx:40-61 and :98-100:

const handleSubmit: InstrumentSubmitHandler = async (result) => {
  const createRecordResponse = await axios.post<InstrumentRecord>('/v1/instrument-records', { /* ... */ });
  // ...
  const record = createRecordResponse.data;
  const presignedUrlsResponse = await axios.get<$PresignedUrls>(
    `/v1/instrument-records/${record.id}/files/upload-urls`
  );
  // ... PUT each file, then:
  await axios.post(`/v1/instrument-records/${record.id}/files/upload-complete`, { uploads });
};

packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx:51: Submit is disabled only while PENDING or SUBMITTED, so it is live again after FAILED.

Reproduce

  1. Start a session and open a file instrument. Add a large file.
  2. Press Submit, and cut the network (DevTools "Offline") while the progress bar is moving. Wait for "Something went wrong".
  3. Restore the network and press Submit again. It succeeds.

Actual: two records exist for the subject. The first is still pending: true and has a partial upload in storage. It is invisible in the subject table but counted in the dashboard's Total Records.
Expected: the retry resumes the same pending record (new upload URLs, re-upload, upload-complete), and exactly one record exists.

Tests

  • apps/web/src/routes/_app/instruments/render/__tests__/$id.test.tsx: it('should reuse the pending record when a failed file upload is retried, so the retry does not create a second record').
  • e2e, testing/src/specs/instrument-completion.spec.ts (or record-file-deletion.spec.ts): route the first presigned PUT to fail with page.route, retry, and assert the subject has one file record.

Suggested fix

In RouteComponent, keep the id of the record created for the current attempt in a useRef. When handleSubmit runs for a FILE result and that ref is set, skip the POST and resume from upload-urls for the existing record. Clear the ref once upload-complete succeeds. Separately, count() should apply the same pending filter that find uses.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    Area: Webapps/web (clinician-facing React SPA)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). None of the files involved has changed since this was filed.

    Gaps in the suggested fix

    • A lost upload-complete response makes every retry fail. That POST has the default 10 s timeout and is never retried automatically (apps/web/src/services/axios.ts:37, :108-109). If the server commits and the response is lost, the record is no longer pending, so the resumed GET upload-urls returns 409 Upload already completed (apps/api/src/instrument-records/files/files.service.ts:147-148). Treat that 409 as success and clear the ref. Pass meta: { disableDefaultErrorNotification: true } on the resumed GET, or the interceptor shows an "HTTP Request Failed" toast over a retry that worked.
    • Upload URLs expire after 5 minutes (apps/api/src/storage/storage.service.ts:87), and $id.tsx fetches all of them up front (:59-61), then PUTs one file at a time (:74-96). If the earlier files take longer than 5 minutes, a later PUT is rejected, on the first attempt and on every resume. That needs its own issue (fetch each URL just before its PUT).

    What the ref does not cover

    If POST /v1/instrument-records times out after the server commits it, no ref is set and the retry still creates a second record. A record abandoned by leaving the page after a failure stays pending for good, as do the ones #1708 writes. Nothing can delete their files: deleteById (apps/api/src/instrument-records/instrument-records.service.ts:168-179) and the subject force-delete (apps/api/src/subjects/subjects.service.ts:143-160) build storage keys from InstrumentRecordFile rows, which only setUploadComplete writes (files.service.ts:106-117). A follow-up sweeper could delete pending records past a cutoff, deriving keys the way getPresignedUploadUrls does (files.service.ts:86-99), on a setTimeout loop like GatewaySynchronizer's (gateway.synchronizer.ts:252-256), so no new dependency. That is Medium difficulty, so I would keep it out of this issue.

    count() and the other unfiltered reads

    SummaryService is the only caller of count() (apps/api/src/summary/summary.service.ts:40, :44), so the filter can go inside count() and fix the total and the trend together. Two more reads skip it: findInstrumentIdsBySubject (apps/api/src/instruments/instruments.service.ts:640-644), which puts a FILE instrument whose only record for the subject is pending into the subject table's selector with no rows behind it, and the export pipeline's $match (instrument-records.service.ts:628-636). Define the filter once as a Prisma where and once as a raw $match. #1807 adds another raw copy in summarizeRecords, so whichever lands second should switch to the shared constant. If the two reads above move to it in the same PR, each needs its own unit test.

    Tests

    • $id.test.tsx: also it('should finish a retry whose upload-urls request returns 409, so a lost upload-complete response does not fail every retry').
    • instrument-records.service.test.ts: it('should not count records whose file upload never completed, so abandoned attempts stay out of Total Records'). The existing count tests pin the exact where (:960-980) and need updating.
    • e2e for the retry: the proposed assertion ("the subject has one file record") already passes on main, because find hides the abandoned record. Count the page's POST …/v1/instrument-records requests and expect one. CI runs no object store and presigned URLs point at STORAGE_ENDPOINT (http://localhost:9000), so route every presigned PUT with page.route: abort the first, fulfil the rest with 200. upload-complete touches only the database, as record-file-deletion.spec.ts already relies on. Put it in the file instrument completion block fix: resolve 26 low-difficulty issues, one commit each #1825 adds to instrument-completion.spec.ts, once fix: resolve 26 low-difficulty issues, one commit each #1825 merges.
    • e2e for count(), in dashboard.spec.ts: create a group, create a FILE record in it as record-file-deletion.spec.ts does without calling upload-complete, and expect GET /api/v1/summary?groupId=<id> to return counts.records of 0. Check the API rather than the Total Records card: libui's StatisticCard counts up from 0, so a card showing 0 passes on main too.

    Related: #1668 also produces a duplicate record, through a double submit rather than a retry. It is not a duplicate of this issue.

    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: APIapps/api (NestJS backend, Prisma models, CASL permissions)Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions