Skip to content

Commit 5873aff

Browse files
committed
fix(files): never pair a stale file record with a newer version number
1 parent 18188ce commit 5873aff

7 files changed

Lines changed: 81 additions & 13 deletions

File tree

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2068,7 +2068,7 @@
20682068
"get": {
20692069
"operationId": "getFile",
20702070
"summary": "Get File Metadata",
2071-
"description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.",
2071+
"description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts. A file rewritten continuously while it is read returns `409`; retry.\n\nOAuth scope: `api:read`.",
20722072
"x-sim-operation": "files.read_metadata",
20732073
"x-oauth-scope": "api:read",
20742074
"tags": ["Files"],
@@ -2145,6 +2145,9 @@
21452145
"404": {
21462146
"$ref": "#/components/responses/NotFound"
21472147
},
2148+
"409": {
2149+
"$ref": "#/components/responses/Conflict"
2150+
},
21482151
"429": {
21492152
"$ref": "#/components/responses/RateLimited"
21502153
},

‎apps/sim/lib/api/contracts/v2/openapi/files-audit.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -848,8 +848,8 @@ const declaredRoutes = [
848848
operationId: 'getFile',
849849
summary: 'Get File Metadata',
850850
description:
851-
'Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.',
852-
errors: RESOURCE_ERRORS,
851+
'Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts. A file rewritten continuously while it is read returns `409`; retry.',
852+
errors: RESOURCE_CONFLICT_ERRORS,
853853
success: { description: 'File metadata and public-share state.' },
854854
}),
855855
{

‎apps/sim/lib/api/mcp/generated/v2-operations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,7 @@ export const V2_MCP_OPERATIONS = {
10351035
contract: v2GetFileContract,
10361036
summary: 'Get File Metadata',
10371037
description:
1038-
'Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.',
1038+
'Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts. A file rewritten continuously while it is read returns `409`; retry.\n\nOAuth scope: `api:read`.',
10391039
handler: () => import('@/app/api/v2/files/[fileId]/metadata/route').then((route) => route.GET),
10401040
},
10411041
getFileShare: {

‎apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,34 @@ describe('workspace file version history in PostgreSQL', () => {
271271
expect(await objectExists(fixture.firstKey)).toBe(false)
272272
})
273273

274+
it('reports a stale record as unresolvable once a coalesced write released its key', async () => {
275+
const fixture = await seedFile('original')
276+
const write = { source: 'collab', authorUserId: fixture.aliceId } as const
277+
await updateWorkspaceFileContent(
278+
fixture.workspaceId,
279+
fixture.fileId,
280+
fixture.aliceId,
281+
Buffer.from('draft one'),
282+
undefined,
283+
{ version: write }
284+
)
285+
const stale = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
286+
if (!stale) throw new Error('file missing')
287+
await updateWorkspaceFileContent(
288+
fixture.workspaceId,
289+
fixture.fileId,
290+
fixture.aliceId,
291+
Buffer.from('draft two'),
292+
undefined,
293+
{ version: write }
294+
)
295+
const fresh = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
296+
if (!fresh) throw new Error('file missing')
297+
298+
expect(await getWorkspaceFileVersionNumberForRecord(stale)).toBeNull()
299+
expect(await getWorkspaceFileVersionNumberForRecord(fresh)).toBe(2)
300+
})
301+
274302
it('never folds deliberate writes, and repoints the head for identical bytes', async () => {
275303
const fixture = await seedFile('original')
276304
const write = { source: 'api', authorUserId: fixture.aliceId } as const

‎apps/sim/lib/uploads/contexts/workspace/workspace-file-versions.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -498,21 +498,21 @@ export async function getCurrentWorkspaceFileVersion(
498498
}
499499

500500
/**
501-
* The version number of the content a file record describes. Matched by the record's storage key, so
502-
* it stays exact even if a write committed after the record was read: that record's key still names
503-
* its own version row. A key with no row is either a file with no history (implicit version 1) or
504-
* bytes a collaborative write replaced within the same version, which keeps the head's number.
501+
* The version number of the content a file record describes, matched by the record's storage key so
502+
* it stays exact even if a write committed after the record was read. Returns null when the record
503+
* is stale in a way its key cannot answer — a later write replaced those bytes within their version
504+
* and released the key — so the caller re-reads the record rather than guess.
505505
*/
506506
export async function getWorkspaceFileVersionNumberForRecord(
507507
file: Pick<WorkspaceFileVersionSubject, 'id' | 'key'>
508-
): Promise<number> {
508+
): Promise<number | null> {
509509
const [row] = await db
510510
.select({ version: workspaceFileVersion.version })
511511
.from(workspaceFileVersion)
512512
.where(and(eq(workspaceFileVersion.fileId, file.id), eq(workspaceFileVersion.key, file.key)))
513513
.limit(1)
514514
if (row) return row.version
515-
return (await loadWorkspaceFileVersionHead(file.id))?.version ?? 1
515+
return (await loadWorkspaceFileVersionHead(file.id)) ? null : 1
516516
}
517517

518518
/** One version of a file, or null when it never existed or retention removed it. */

‎apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,30 @@ describe('readWorkspaceFileMetadataWithVersion', () => {
189189
).resolves.toEqual({ file, share, currentVersion: 4 })
190190
expect(mocks.getVersionNumberForRecord).toHaveBeenCalledWith(file)
191191
})
192+
193+
it('re-reads a record whose bytes a concurrent write already replaced', async () => {
194+
const rewritten = { ...file, key: 'workspace/ws/data-2.csv' }
195+
mocks.getWorkspaceFile.mockResolvedValueOnce(file).mockResolvedValueOnce(rewritten)
196+
mocks.getVersionNumberForRecord.mockResolvedValueOnce(null).mockResolvedValueOnce(5)
197+
198+
await expect(
199+
readWorkspaceFileMetadataWithVersion.execute({
200+
principal,
201+
input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' },
202+
})
203+
).resolves.toEqual({ file: rewritten, share, currentVersion: 5 })
204+
})
205+
206+
it('answers a retryable conflict when every read is already stale', async () => {
207+
mocks.getWorkspaceFile.mockResolvedValue(file)
208+
mocks.getVersionNumberForRecord.mockResolvedValue(null)
209+
210+
await expect(
211+
readWorkspaceFileMetadataWithVersion.execute({
212+
principal,
213+
input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' },
214+
})
215+
).rejects.toMatchObject({ code: 'conflict' })
216+
expect(mocks.getVersionNumberForRecord).toHaveBeenCalledTimes(3)
217+
})
192218
})

‎apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,29 @@ export const readWorkspaceFileMetadata = defineAuthorizedWorkspaceFileUseCase({
5959
execute: executeReadWorkspaceFileMetadata,
6060
})
6161

62+
/** Reads of a record a concurrent write keeps replacing before the version is reported as a conflict. */
63+
const CURRENT_VERSION_READ_ATTEMPTS = 3
64+
6265
/**
6366
* The same read plus the current version number, for the public metadata surface. Kept separate so
6467
* the many internal callers of {@link readWorkspaceFileMetadata} pay no extra query. The number is
6568
* resolved from the returned record's own storage key, so it always identifies the content that
66-
* record describes.
69+
* record describes; a record whose bytes a concurrent write already replaced is read again, and a
70+
* file rewritten on every attempt answers a retryable conflict rather than a mismatched version.
6771
*/
6872
export const readWorkspaceFileMetadataWithVersion = defineAuthorizedWorkspaceFileUseCase({
6973
operation: fileOperations.readMetadata,
7074
resolveContext: ({ input }: { input: ReadWorkspaceFileMetadataInput }) =>
7175
resolveActiveWorkspaceFileContext(input),
7276
async execute(args): Promise<ReadWorkspaceFileMetadataWithVersionResult> {
73-
const result = await executeReadWorkspaceFileMetadata(args)
74-
return { ...result, currentVersion: await getWorkspaceFileVersionNumberForRecord(result.file) }
77+
for (let attempt = 0; attempt < CURRENT_VERSION_READ_ATTEMPTS; attempt++) {
78+
const result = await executeReadWorkspaceFileMetadata(args)
79+
const currentVersion = await getWorkspaceFileVersionNumberForRecord(result.file)
80+
if (currentVersion !== null) return { ...result, currentVersion }
81+
}
82+
throw new OrchestrationError(
83+
'conflict',
84+
'The file changed while it was being read; retry the request'
85+
)
7586
},
7687
})

0 commit comments

Comments
 (0)