Skip to content

Commit 0d640aa

Browse files
authored
fix(uploads): make execution attachment completion replay-safe (#6601)
finalizeExecutionAttachment reported a completedFileId. That marker is what routes a replayed completion into loadCompletedUploadPurpose, which handled only workspace_file and threw a bare Error otherwise -- unclassified, so the route rendered a generic 500. Its structurally identical twin, finalizeMothershipAttachment, correctly reports nothing. Both are metadata-backed and idempotent by storage key, exactly as the finalizeUploadPurpose TSDoc already states, so neither needs the marker: their replays are correct through the finalizer itself. Drop it from the execution finalizer so the two twins agree. loadCompletedUploadPurpose becomes an exhaustive switch, matching the sibling finalizeUploadPurpose switch, so adding a purpose is a compile error until its replay behavior is decided rather than a runtime 500. The residual arm throws a classified UploadSessionError('internal') instead of a bare Error. markUploadSessionCompleted no longer clears a marker it was not given. A finalizer that records one inside its own registration transaction -- markUploadSessionFileRegistered does this for workspace_file -- would otherwise have it overwritten with null, and both the abort guard and the expiry sweep key on it: cleanupExpiredUploadSessions only treats a finalizing session as disposable when completedFileId is null. This is a no-op for every current path, since markUploadSessionCompleted moves the session to completed, which is neither abortable nor a cleanup candidate. Latent only. No shipped client replays a completion: the sole producer, uploadWorkflowAttachments, mints a fresh session per file and never retries, requestJson does not retry, and a concurrent double-submit is already a clean 409 from claimSession. Tests pin the invariant rather than the symptom: a Record over the purpose union is a compile-time gate on which route each purpose replays through, and the cases assert that idempotent purposes report no marker and reject cleanly if they ever reach the loader.
1 parent a6ebfec commit 0d640aa

3 files changed

Lines changed: 105 additions & 6 deletions

File tree

apps/sim/app/api/files/uploads/finalizers.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ vi.mock('@/lib/users/queries', () => ({
6969
requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId)!,
7070
}))
7171

72-
import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers'
72+
import {
73+
finalizeUploadPurpose,
74+
loadCompletedUploadPurpose,
75+
} from '@/app/api/files/uploads/finalizers'
76+
import type { InternalUploadPurpose } from '@/app/api/files/uploads/purposes'
7377

7478
const now = new Date('2026-08-04T12:00:00.000Z')
7579
const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' }
@@ -138,6 +142,70 @@ const workspaceFile = {
138142
updatedAt: now,
139143
}
140144

145+
/**
146+
* How each purpose replays a completion. `Record` over the union is a
147+
* compile-time completeness gate: adding a purpose fails to build until its
148+
* replay behavior is declared here.
149+
*/
150+
const REPLAY_ROUTE: Record<InternalUploadPurpose, 'loader' | 'idempotent-finalizer'> = {
151+
workspace_file: 'loader',
152+
profile_picture: 'idempotent-finalizer',
153+
workspace_logo: 'idempotent-finalizer',
154+
mothership_attachment: 'idempotent-finalizer',
155+
execution_attachment: 'idempotent-finalizer',
156+
}
157+
158+
const purposesReplayedBy = (route: 'loader' | 'idempotent-finalizer') =>
159+
(Object.keys(REPLAY_ROUTE) as InternalUploadPurpose[]).filter((p) => REPLAY_ROUTE[p] === route)
160+
161+
describe('completion replay contract', () => {
162+
const REPLAY_VIA_IDEMPOTENT_FINALIZER = purposesReplayedBy('idempotent-finalizer')
163+
164+
beforeEach(() => {
165+
vi.clearAllMocks()
166+
})
167+
168+
it.each(REPLAY_VIA_IDEMPOTENT_FINALIZER)(
169+
'does not mark %s as loader-backed, so its replay re-runs the idempotent finalizer',
170+
async (purpose) => {
171+
mockSelectLimit.mockResolvedValue([])
172+
mockInsertReturning.mockResolvedValue([metadataRow])
173+
const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete')
174+
175+
const finalized = await finalizeUploadPurpose({
176+
session: { ...uploadSession, purpose },
177+
actor,
178+
principal,
179+
request,
180+
})
181+
182+
expect(finalized.completedFileId).toBeUndefined()
183+
}
184+
)
185+
186+
it.each(REPLAY_VIA_IDEMPOTENT_FINALIZER)(
187+
'reports a classified error rather than an unhandled crash if %s ever reaches the loader',
188+
async (purpose) => {
189+
await expect(loadCompletedUploadPurpose({ ...uploadSession, purpose })).rejects.toMatchObject(
190+
{ code: 'internal' }
191+
)
192+
}
193+
)
194+
195+
it('loads workspace_file from its durable record on replay', async () => {
196+
mockGetWorkspaceFile.mockResolvedValueOnce(workspaceFile)
197+
198+
const loaded = await loadCompletedUploadPurpose({
199+
...uploadSession,
200+
purpose: purposesReplayedBy('loader')[0],
201+
completedFileId: workspaceFile.id,
202+
})
203+
204+
expect(loaded).toMatchObject({ id: workspaceFile.id })
205+
expect(mockGetWorkspaceFile).toHaveBeenCalledTimes(1)
206+
})
207+
})
208+
141209
describe('upload purpose finalizers', () => {
142210
beforeEach(() => {
143211
vi.clearAllMocks()

apps/sim/app/api/files/uploads/finalizers.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ interface FinalizeUploadPurposeParams {
5959

6060
interface FinalizedUploadPurpose {
6161
value: UploadPurposeResult
62+
/**
63+
* Recorded on the session so a replayed completion returns the original
64+
* result instead of re-running a finalizer with one-time side effects.
65+
*
66+
* Set only for a purpose {@link loadCompletedUploadPurpose} can reload; an
67+
* already-idempotent finalizer must leave it undefined so replays flow back
68+
* through the finalizer itself.
69+
*/
6270
completedFileId?: string
6371
}
6472

@@ -111,13 +119,30 @@ export async function finalizeUploadPurpose({
111119
}
112120
}
113121

122+
/**
123+
* Reloads the durable result of an already-completed session, for the purposes
124+
* that report a {@link FinalizedUploadPurpose.completedFileId}.
125+
*
126+
* The switch is exhaustive so that adding a purpose is a compile error until
127+
* its replay behavior is decided here.
128+
*/
114129
export async function loadCompletedUploadPurpose(
115130
session: UploadSessionRecord
116131
): Promise<UploadPurposeResult> {
117-
if (session.purpose !== 'workspace_file') {
118-
throw new Error(`Upload purpose ${session.purpose} has no durable file result`)
132+
switch (session.purpose) {
133+
case 'workspace_file':
134+
return toV2File(await loadCompletedWorkspaceFileUpload(session))
135+
case 'profile_picture':
136+
case 'workspace_logo':
137+
case 'mothership_attachment':
138+
case 'execution_attachment':
139+
case 'table_import':
140+
case 'knowledge_document':
141+
throw new UploadSessionError(
142+
'internal',
143+
`Upload purpose ${session.purpose} recorded a completed file but has no durable loader`
144+
)
119145
}
120-
return toV2File(await loadCompletedWorkspaceFileUpload(session))
121146
}
122147

123148
async function finalizeInternalWorkspaceFile(
@@ -314,7 +339,6 @@ async function finalizeExecutionAttachment(
314339
key: session.storageKey,
315340
context: 'execution',
316341
},
317-
completedFileId: finalized.file.id,
318342
}
319343
}
320344

apps/sim/lib/uploads/upload-session/service.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,13 @@ export async function completeUploadSession<T>(params: {
701701
}
702702
}
703703

704+
/**
705+
* The completion marker is only written when the finalizer reports one. A
706+
* finalizer that instead records it inside its own registration transaction —
707+
* see `markUploadSessionFileRegistered` — keeps that value: clearing it would
708+
* let the abort guard and the expiry sweep treat a session whose durable
709+
* resource already exists as disposable.
710+
*/
704711
async function markUploadSessionCompleted(
705712
session: UploadSessionRecord,
706713
leaseId: string,
@@ -711,7 +718,7 @@ async function markUploadSessionCompleted(
711718
.update(uploadSession)
712719
.set({
713720
status: 'completed',
714-
completedFileId,
721+
...(completedFileId !== null ? { completedFileId } : {}),
715722
completedAt,
716723
processingLeaseId: null,
717724
processingLeaseExpiresAt: null,

0 commit comments

Comments
 (0)