Skip to content

Commit 6d30efa

Browse files
fix(files): release folder lock before upload setup
1 parent 0e40ca0 commit 6d30efa

3 files changed

Lines changed: 88 additions & 51 deletions

File tree

apps/sim/app/api/v2/files/uploads/route.test.ts

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ const {
99
mockResolveWorkspaceAccess,
1010
mockCreateUploadSession,
1111
mockLoadActiveFolderPathIndex,
12+
mockWithFolderTreeLock,
1213
} = vi.hoisted(() => ({
1314
mockCheckRateLimit: vi.fn(),
1415
mockResolveWorkspaceAccess: vi.fn(),
1516
mockCreateUploadSession: vi.fn(),
1617
mockLoadActiveFolderPathIndex: vi.fn(),
18+
mockWithFolderTreeLock: vi.fn(),
1719
}))
1820

1921
vi.mock('@/app/api/v1/middleware', () => ({
@@ -29,6 +31,10 @@ vi.mock('@/lib/folders/queries', () => ({
2931
loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
3032
}))
3133

34+
vi.mock('@/lib/folders/locks', () => ({
35+
withFolderTreeLock: mockWithFolderTreeLock,
36+
}))
37+
3238
vi.mock('@/lib/uploads/upload-session/service', () => ({
3339
createUploadSession: mockCreateUploadSession,
3440
}))
@@ -44,6 +50,41 @@ const RATE_LIMIT = {
4450
remaining: 99,
4551
resetAt: new Date('2026-08-03T22:00:00.000Z'),
4652
}
53+
const UPLOAD_SESSION = {
54+
id: 'upload-1',
55+
workspaceId: WORKSPACE_ID,
56+
userId: 'user-1',
57+
knowledgeBaseId: null,
58+
workflowId: null,
59+
executionId: null,
60+
purpose: 'workspace_file',
61+
method: 'put',
62+
storageContext: 'workspace',
63+
storageKey: `${WORKSPACE_ID}/file.csv`,
64+
finalKey: `${WORKSPACE_ID}/file.csv`,
65+
stagingKey: 'upload-sessions/upload-1/file.csv',
66+
storageProvider: 's3',
67+
providerUploadId: null,
68+
fileName: 'file.csv',
69+
contentType: 'text/csv',
70+
fileSize: 10,
71+
partSize: null,
72+
partCount: null,
73+
status: 'uploading',
74+
uploadToken: 'signed-upload-token',
75+
metadata: {},
76+
completedFileId: null,
77+
error: null,
78+
expiresAt: new Date('2026-08-04T21:00:00.000Z'),
79+
createdAt: new Date('2026-08-03T21:00:00.000Z'),
80+
updatedAt: new Date('2026-08-03T21:00:00.000Z'),
81+
completedAt: null,
82+
transfer: {
83+
method: 'put',
84+
url: 'https://storage.example/upload',
85+
headers: { 'content-type': 'text/csv' },
86+
},
87+
}
4788

4889
function request(body: Record<string, unknown>) {
4990
return POST(
@@ -60,46 +101,15 @@ describe('POST /api/v2/files/uploads', () => {
60101
vi.clearAllMocks()
61102
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
62103
mockResolveWorkspaceAccess.mockResolvedValue(null)
104+
mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) =>
105+
operation({})
106+
)
63107
mockLoadActiveFolderPathIndex.mockResolvedValue({
64108
rowById: new Map(),
65109
pathById: new Map(),
66110
idByPath: new Map([['/Reports', 'folder-reports']]),
67111
})
68-
mockCreateUploadSession.mockResolvedValue({
69-
id: 'upload-1',
70-
workspaceId: WORKSPACE_ID,
71-
userId: 'user-1',
72-
knowledgeBaseId: null,
73-
workflowId: null,
74-
executionId: null,
75-
purpose: 'workspace_file',
76-
method: 'put',
77-
storageContext: 'workspace',
78-
storageKey: `${WORKSPACE_ID}/file.csv`,
79-
finalKey: `${WORKSPACE_ID}/file.csv`,
80-
stagingKey: 'upload-sessions/upload-1/file.csv',
81-
storageProvider: 's3',
82-
providerUploadId: null,
83-
fileName: 'file.csv',
84-
contentType: 'text/csv',
85-
fileSize: 10,
86-
partSize: null,
87-
partCount: null,
88-
status: 'uploading',
89-
uploadToken: 'signed-upload-token',
90-
metadata: {},
91-
completedFileId: null,
92-
error: null,
93-
expiresAt: new Date('2026-08-04T21:00:00.000Z'),
94-
createdAt: new Date('2026-08-03T21:00:00.000Z'),
95-
updatedAt: new Date('2026-08-03T21:00:00.000Z'),
96-
completedAt: null,
97-
transfer: {
98-
method: 'put',
99-
url: 'https://storage.example/upload',
100-
headers: { 'content-type': 'text/csv' },
101-
},
102-
})
112+
mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION)
103113
})
104114

105115
it('creates one signed PUT session for a small file', async () => {
@@ -166,7 +176,21 @@ describe('POST /api/v2/files/uploads', () => {
166176
)
167177
})
168178

169-
it('resolves a canonical folder path while the upload session is created', async () => {
179+
it('releases the folder tree lock before creating an upload session', async () => {
180+
let lockHeld = false
181+
mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => {
182+
lockHeld = true
183+
try {
184+
return await operation({})
185+
} finally {
186+
lockHeld = false
187+
}
188+
})
189+
mockCreateUploadSession.mockImplementationOnce(async () => {
190+
expect(lockHeld).toBe(false)
191+
return UPLOAD_SESSION
192+
})
193+
170194
const response = await request({
171195
workspaceId: WORKSPACE_ID,
172196
name: 'file.csv',

apps/sim/app/api/v2/files/uploads/route.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { createUploadSession } from '@/lib/uploads/upload-session/service'
88
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
99
import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils'
10-
import { withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders'
10+
import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders'
1111
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
1212
import {
1313
v2CaughtOrchestrationError,
@@ -40,24 +40,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4040
const { workspaceId, name, contentType, size, folderPath } = parsed.data.body
4141
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
4242
if (access) return v2WorkspaceAccessError(access)
43-
const mutation = await withResolvedFolderPathMutation({
43+
const resolution = await resolveFolderPathIdentity({
4444
workspaceId,
4545
resourceType: 'file',
4646
path: folderPath ?? '/',
47-
mutate: (folderId) =>
48-
createUploadSession({
49-
workspaceId,
50-
userId,
51-
purpose: 'workspace_file',
52-
fileName: name,
53-
contentType,
54-
fileSize: size,
55-
metadata: { folderId },
56-
localOrigin: request.nextUrl.origin,
57-
}),
5847
})
59-
if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found')
60-
const session = mutation.value
48+
if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found')
49+
const session = await createUploadSession({
50+
workspaceId,
51+
userId,
52+
purpose: 'workspace_file',
53+
fileName: name,
54+
contentType,
55+
fileSize: size,
56+
metadata: { folderId: resolution.folderId },
57+
localOrigin: request.nextUrl.origin,
58+
})
6159
return v2Data(
6260
{
6361
session: toV2FileUpload(session, null),

apps/sim/app/api/v2/lib/folders.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ export type ResolvedFolderPathMutation<T> =
2525
| { found: false }
2626
| { found: true; folderId: string | null; index: FolderPathIndex<FolderRow>; value: T }
2727

28+
export type ResolvedFolderPathIdentity = { found: false } | { found: true; folderId: string | null }
29+
30+
/** Resolves a canonical path to its stable internal identity under the folder tree lock. */
31+
export async function resolveFolderPathIdentity(params: {
32+
workspaceId: string
33+
resourceType: FolderResourceType
34+
path: string
35+
}): Promise<ResolvedFolderPathIdentity> {
36+
return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => {
37+
const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx)
38+
const folderId = resolveFolderPathId(index, params.path)
39+
return folderId === undefined ? { found: false } : { found: true, folderId }
40+
})
41+
}
42+
2843
/** Resolves a canonical path and keeps that folder tree stable through a resource mutation. */
2944
export async function withResolvedFolderPathMutation<T>(params: {
3045
workspaceId: string

0 commit comments

Comments
 (0)