Repository navigation
Description
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
- Start a session and open a file instrument. Add a large file.
- Press Submit, and cut the network (DevTools "Offline") while the progress bar is moving. Wait for "Something went wrong".
- 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(orrecord-file-deletion.spec.ts): route the first presigned PUT to fail withpage.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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). None of the files involved has changed since this was filed.Gaps in the suggested fix
upload-completeresponse 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 resumedGET upload-urlsreturns 409Upload already completed(apps/api/src/instrument-records/files/files.service.ts:147-148). Treat that 409 as success and clear the ref. Passmeta: { disableDefaultErrorNotification: true }on the resumed GET, or the interceptor shows an "HTTP Request Failed" toast over a retry that worked.apps/api/src/storage/storage.service.ts:87), and$id.tsxfetches 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-recordstimes 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 fromInstrumentRecordFilerows, which onlysetUploadCompletewrites (files.service.ts:106-117). A follow-up sweeper could delete pending records past a cutoff, deriving keys the waygetPresignedUploadUrlsdoes (files.service.ts:86-99), on asetTimeoutloop likeGatewaySynchronizer'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 readsSummaryServiceis the only caller ofcount()(apps/api/src/summary/summary.service.ts:40,:44), so the filter can go insidecount()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 Prismawhereand once as a raw$match. #1807 adds another raw copy insummarizeRecords, 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: alsoit('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 existingcounttests pin the exactwhere(:960-980) and need updating.main, becausefindhides the abandoned record. Count the page'sPOST …/v1/instrument-recordsrequests and expect one. CI runs no object store and presigned URLs point atSTORAGE_ENDPOINT(http://localhost:9000), so route every presigned PUT withpage.route: abort the first, fulfil the rest with 200.upload-completetouches only the database, asrecord-file-deletion.spec.tsalready relies on. Put it in thefile instrument completionblock fix: resolve 26 low-difficulty issues, one commit each #1825 adds toinstrument-completion.spec.ts, once fix: resolve 26 low-difficulty issues, one commit each #1825 merges.count(), indashboard.spec.ts: create a group, create a FILE record in it asrecord-file-deletion.spec.tsdoes without callingupload-complete, and expectGET /api/v1/summary?groupId=<id>to returncounts.recordsof 0. Check the API rather than the Total Records card: libui'sStatisticCardcounts up from 0, so a card showing 0 passes onmaintoo.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.