Skip to content

Commit cc2cbc0

Browse files
committed
fix(files): release file history through the outbox after the current object is gone
1 parent dc4f76c commit cc2cbc0

4 files changed

Lines changed: 82 additions & 51 deletions

File tree

‎apps/sim/background/cleanup-file-versions.ts‎

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher'
88
import type { PlanCategory } from '@/lib/billing/plan-helpers'
99
import { DEFAULT_DELETE_CHUNK_SIZE } from '@/lib/cleanup/batch-delete'
1010
import { retentionCleanupQueue } from '@/lib/cleanup/queue'
11-
import { StorageService } from '@/lib/uploads'
11+
import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
1212
import {
1313
FILE_VERSION_RETENTION_KEEP_LATEST,
1414
MAX_SUPERSEDED_FILE_VERSIONS,
@@ -85,7 +85,6 @@ function selectExpiredVersions(fileIds: string[], cutoff: Date, maxSuperseded: n
8585
const ranked = cleanupDb
8686
.select({
8787
id: workspaceFileVersion.id,
88-
key: workspaceFileVersion.key,
8988
supersededAt: workspaceFileVersion.supersededAt,
9089
rank: sql<number>`row_number() over (partition by ${workspaceFileVersion.fileId} order by ${workspaceFileVersion.version} desc)`.as(
9190
'rank'
@@ -101,7 +100,7 @@ function selectExpiredVersions(fileIds: string[], cutoff: Date, maxSuperseded: n
101100
.as('ranked')
102101

103102
return cleanupDb
104-
.select({ id: ranked.id, key: ranked.key })
103+
.select({ id: ranked.id })
105104
.from(ranked)
106105
.where(
107106
and(
@@ -113,39 +112,34 @@ function selectExpiredVersions(fileIds: string[], cutoff: Date, maxSuperseded: n
113112
}
114113

115114
/**
116-
* Deletes the stored objects first and only then the rows whose objects are gone, so a failed
117-
* delete leaves its row — and the next run retries it — instead of orphaning the object.
115+
* Deletes the expired version rows and enqueues their stored objects on the storage-cleanup outbox
116+
* in the same transaction, so a row never outlives its release and every released object is
117+
* deleted durably — the outbox retries failures and treats an already-missing object as done.
118118
*/
119-
async function deleteVersions(rows: Array<{ id: string; key: string }>, label: string) {
120-
const failedKeys = new Set<string>()
121-
for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) {
122-
const deletion = await StorageService.deleteFiles(
123-
batch.map((row) => row.key),
124-
'workspace'
125-
)
126-
for (const { key, error } of deletion.failed) {
127-
failedKeys.add(key)
128-
logger.error(`[${label}] Failed to delete file version object ${key}`, { error })
129-
}
130-
}
131-
const removable = rows.filter((row) => !failedKeys.has(row.key))
119+
async function deleteVersions(rows: Array<{ id: string }>) {
132120
let deleted = 0
133-
for (const batch of chunkArray(removable, DEFAULT_DELETE_CHUNK_SIZE)) {
134-
const removed = await cleanupDb
135-
.delete(workspaceFileVersion)
136-
.where(
137-
and(
138-
inArray(
139-
workspaceFileVersion.id,
140-
batch.map((row) => row.id)
141-
),
142-
isNotNull(workspaceFileVersion.supersededAt)
121+
for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) {
122+
deleted += await cleanupDb.transaction(async (tx) => {
123+
const removed = await tx
124+
.delete(workspaceFileVersion)
125+
.where(
126+
and(
127+
inArray(
128+
workspaceFileVersion.id,
129+
batch.map((row) => row.id)
130+
),
131+
isNotNull(workspaceFileVersion.supersededAt)
132+
)
143133
)
134+
.returning({ key: workspaceFileVersion.key })
135+
await enqueueWorkspaceFileStorageCleanups(
136+
tx,
137+
removed.map((row) => row.key)
144138
)
145-
.returning({ id: workspaceFileVersion.id })
146-
deleted += removed.length
139+
return removed.length
140+
})
147141
}
148-
return { deleted, failed: rows.length - removable.length }
142+
return deleted
149143
}
150144

151145
export async function runCleanupFileVersions(payload: CleanupJobPayload): Promise<void> {
@@ -163,25 +157,21 @@ export async function runCleanupFileVersions(payload: CleanupJobPayload): Promis
163157
)
164158

165159
let deleted = 0
166-
let failed = 0
167160
for (const group of chunkArray(workspaceIds, WORKSPACES_PER_QUERY)) {
168161
const candidates = await selectCandidateFileIds(group, cutoff, maxSuperseded)
169162
for (const fileIds of chunkArray(candidates, FILES_PER_QUERY)) {
170163
for (let batch = 0; batch < MAX_BATCHES_PER_CHUNK; batch++) {
171164
const expired = await selectExpiredVersions(fileIds, cutoff, maxSuperseded)
172165
if (expired.length === 0) break
173-
const result = await deleteVersions(expired, label)
174-
deleted += result.deleted
175-
failed += result.failed
176-
if (expired.length < VERSIONS_PER_BATCH || result.deleted === 0) break
166+
const removed = await deleteVersions(expired)
167+
deleted += removed
168+
if (expired.length < VERSIONS_PER_BATCH || removed === 0) break
177169
}
178170
}
179171
}
180172

181173
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
182-
logger.info(
183-
`[${label}] File version cleanup: ${deleted} deleted, ${failed} failed in ${elapsed}s`
184-
)
174+
logger.info(`[${label}] File version cleanup: ${deleted} released in ${elapsed}s`)
185175
}
186176

187177
export const cleanupFileVersionsTask = task({

‎apps/sim/background/cleanup-soft-deletes.test.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,25 @@ describe('cleanup soft deletes', () => {
129129
})
130130
})
131131

132-
it('releases the version history of expired workspace files before purging their objects', async () => {
132+
it('releases version history only for files whose current object was deleted', async () => {
133133
mockSelectRowsByIdChunks
134134
.mockResolvedValueOnce([])
135135
.mockResolvedValueOnce([])
136136
.mockResolvedValueOnce([
137137
{
138-
id: 'file-1',
139-
key: 'workspace/ws-1/file-1',
138+
id: 'file-purged',
139+
key: 'workspace/ws-1/file-purged',
140140
workspaceId: 'ws-1',
141141
context: 'workspace',
142142
sizeBytes: 5,
143143
},
144+
{
145+
id: 'file-failed',
146+
key: 'workspace/ws-1/file-failed',
147+
workspaceId: 'ws-1',
148+
context: 'workspace',
149+
sizeBytes: 4,
150+
},
144151
{
145152
id: 'chat-file',
146153
key: 'mothership/chat-file',
@@ -149,16 +156,20 @@ describe('cleanup soft deletes', () => {
149156
sizeBytes: 3,
150157
},
151158
])
159+
mockDeleteFiles.mockResolvedValueOnce({
160+
deleted: 1,
161+
failed: [{ key: 'workspace/ws-1/file-failed', error: 'storage unavailable' }],
162+
})
152163

153164
await runCleanupSoftDeletes(basePayload)
154165

155166
expect(mockReleaseExpiredWorkspaceFileVersions).toHaveBeenCalledWith(
156167
expect.anything(),
157-
['file-1'],
168+
['file-purged'],
158169
expect.any(Date)
159170
)
160-
expect(mockReleaseExpiredWorkspaceFileVersions.mock.invocationCallOrder[0]).toBeLessThan(
161-
mockDeleteFiles.mock.invocationCallOrder[0]
171+
expect(mockDeleteFiles.mock.invocationCallOrder[0]).toBeLessThan(
172+
mockReleaseExpiredWorkspaceFileVersions.mock.invocationCallOrder[0]
162173
)
163174
})
164175

‎apps/sim/background/cleanup-soft-deletes.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -900,12 +900,16 @@ export async function runCleanupSoftDeletes(
900900
chatCleanup = await prepareChatCleanup([...doomedChatIds], label)
901901
}
902902

903+
const fileCleanup = await cleanupWorkspaceFileStorage(fileScope)
904+
/**
905+
* Only files whose current object is gone are committed to the purge, so a file whose object
906+
* deletion failed keeps its history for the retry instead of losing it ahead of its own delete.
907+
*/
903908
await releaseExpiredWorkspaceFileVersions(
904909
cleanupDb,
905-
fileScope.multiContextRows.filter((row) => row.context === 'workspace').map((row) => row.id),
910+
fileCleanup.multiContextRows.filter((row) => row.context === 'workspace').map((row) => row.id),
906911
retentionDate
907912
)
908-
const fileCleanup = await cleanupWorkspaceFileStorage(fileScope)
909913
if (budgets && fileCleanup.filesFailed) throw new Error('File storage cleanup failed')
910914

911915
let totalDeleted = 0

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
workspaceFileVersion,
1515
} from '@sim/db/schema'
1616
import { generateId } from '@sim/utils/id'
17-
import { asc, eq, inArray, sql } from 'drizzle-orm'
17+
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
1818
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
1919

2020
const fixtureStorage = vi.hoisted(() => ({ root: '' }))
@@ -434,9 +434,15 @@ describe('workspace file version history in PostgreSQL', () => {
434434
const events = await db
435435
.select({ id: outboxEvent.id, payload: outboxEvent.payload })
436436
.from(outboxEvent)
437-
.where(eq(outboxEvent.eventType, WORKSPACE_FILE_STORAGE_CLEANUP_OUTBOX_EVENT))
438-
const enqueuedKeys = events.map((event) => (event.payload as { key: string }).key)
439-
expect(enqueuedKeys).toEqual(expect.arrayContaining(releasedKeys))
437+
.where(
438+
and(
439+
eq(outboxEvent.eventType, WORKSPACE_FILE_STORAGE_CLEANUP_OUTBOX_EVENT),
440+
inArray(sql<string>`${outboxEvent.payload}::jsonb ->> 'key'`, releasedKeys)
441+
)
442+
)
443+
expect(events.map((event) => (event.payload as { key: string }).key).sort()).toEqual(
444+
[...releasedKeys].sort()
445+
)
440446
await db.delete(outboxEvent).where(
441447
inArray(
442448
outboxEvent.id,
@@ -464,6 +470,10 @@ describe('workspace file version history in PostgreSQL', () => {
464470
sql`${workspaceFileVersion.fileId} = ${fixture.fileId} AND ${workspaceFileVersion.supersededAt} IS NOT NULL`
465471
)
466472

473+
const prunedKeys = (await versionRows(fixture.fileId))
474+
.filter((row) => row.version <= 3)
475+
.map((row) => row.key)
476+
467477
await runCleanupFileVersions({
468478
plan: 'pro',
469479
workspaceIds: [fixture.workspaceId],
@@ -474,5 +484,21 @@ describe('workspace file version history in PostgreSQL', () => {
474484
const rows = await versionRows(fixture.fileId)
475485
expect(rows.map((row) => row.version)).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 13])
476486
expect(rows.at(-1)?.supersededAt).toBeNull()
487+
const events = await db
488+
.select({ id: outboxEvent.id })
489+
.from(outboxEvent)
490+
.where(
491+
and(
492+
eq(outboxEvent.eventType, WORKSPACE_FILE_STORAGE_CLEANUP_OUTBOX_EVENT),
493+
inArray(sql<string>`${outboxEvent.payload}::jsonb ->> 'key'`, prunedKeys)
494+
)
495+
)
496+
expect(events).toHaveLength(prunedKeys.length)
497+
await db.delete(outboxEvent).where(
498+
inArray(
499+
outboxEvent.id,
500+
events.map((event) => event.id)
501+
)
502+
)
477503
})
478504
})

0 commit comments

Comments
 (0)