Skip to content

Commit 972e235

Browse files
committed
fix(cleanup): collect a forked workspace upload once no file row names it
A workspace upload kept because a fork attaches it loses its workspace_files row when the source chat is deleted, and collectChatFiles ignored message-level workspace/ keys, so deleting the last fork left the bytes behind. It now also collects an attached workspace/<ws>/ key that no workspace file, legacy file or file version row names, live or soft-deleted, checked in one query per chunk and scoped to the key's workspace, so the reference check still keeps it while another chat there attaches it.
1 parent ec9c9cd commit 972e235

2 files changed

Lines changed: 127 additions & 4 deletions

File tree

‎apps/sim/lib/cleanup/chat-backend-cleanup.integration.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,80 @@ describe('cleanup of a permanently deleted chat', () => {
364364
expect(await objectExists(ownKey)).toBe(false)
365365
})
366366

367+
it('deletes a forked upload once the last chat attaching it is deleted', async () => {
368+
const ownerId = await seedUser()
369+
const workspaceId = await seedWorkspace(ownerId)
370+
const source = await seedChat(ownerId, workspaceId)
371+
const key = `workspace/${workspaceId}/1700000000000-${generateId().slice(0, 8)}-shot.png`
372+
await db.insert(workspaceFiles).values({
373+
id: generateId(),
374+
key,
375+
userId: ownerId,
376+
workspaceId,
377+
context: 'mothership',
378+
chatId: source,
379+
originalName: 'shot.png',
380+
contentType: 'image/png',
381+
sizeBytes: 1,
382+
})
383+
await putObject(key)
384+
const fork = await seedChat(ownerId, workspaceId)
385+
await db.insert(copilotMessages).values({
386+
chatId: fork,
387+
messageId: generateId(),
388+
role: 'user',
389+
content: { role: 'user', content: 'see attached', fileAttachments: [{ key }] },
390+
})
391+
392+
await deleteCopilotChat({ chatId: source, userId: ownerId })
393+
await deliverCleanups()
394+
expect(await objectExists(key)).toBe(true)
395+
396+
await deleteCopilotChat({ chatId: fork, userId: ownerId })
397+
await deliverCleanups()
398+
expect(await objectExists(key)).toBe(false)
399+
})
400+
401+
it('never deletes an attached workspace upload whose row is live or only soft-deleted', async () => {
402+
const ownerId = await seedUser()
403+
const workspaceId = await seedWorkspace(ownerId)
404+
const chatId = await seedChat(ownerId, workspaceId)
405+
const upload = (name: string) =>
406+
`workspace/${workspaceId}/1700000000000-${generateId().slice(0, 8)}-${name}`
407+
const live = upload('live.png')
408+
const restorable = upload('restorable.png')
409+
await db.insert(workspaceFiles).values(
410+
[live, restorable].map((key) => ({
411+
id: generateId(),
412+
key,
413+
userId: ownerId,
414+
workspaceId,
415+
context: 'workspace',
416+
originalName: 'file.png',
417+
contentType: 'image/png',
418+
sizeBytes: 1,
419+
...(key === restorable ? { deletedAt: new Date() } : {}),
420+
}))
421+
)
422+
for (const key of [live, restorable]) await putObject(key)
423+
await db.insert(copilotMessages).values({
424+
chatId,
425+
messageId: generateId(),
426+
role: 'user',
427+
content: {
428+
role: 'user',
429+
content: 'x',
430+
fileAttachments: [{ key: live }, { key: restorable }],
431+
},
432+
})
433+
434+
await deleteCopilotChat({ chatId, userId: ownerId })
435+
await deliverCleanups()
436+
437+
expect(await objectExists(live)).toBe(true)
438+
expect(await objectExists(restorable)).toBe(true)
439+
})
440+
367441
it('places a legacy upload ref by its workspace key, keeping one a remaining chat attaches', async () => {
368442
const ownerId = await seedUser()
369443
const workspaceId = await seedWorkspace(ownerId)

‎apps/sim/lib/cleanup/chat-files.ts‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { dbFor } from '@sim/db'
2-
import { copilotChats, copilotMessages, workspaceFiles } from '@sim/db/schema'
2+
import {
3+
copilotChats,
4+
copilotMessages,
5+
workspaceFile,
6+
workspaceFiles,
7+
workspaceFileVersion,
8+
} from '@sim/db/schema'
39
import { chunkArray } from '@sim/utils/helpers'
410
import { isRecordLike } from '@sim/utils/object'
511
import { and, inArray, isNull, sql } from 'drizzle-orm'
@@ -86,11 +92,30 @@ function fileAttachmentKeys(value: unknown): string[] {
8692
)
8793
}
8894

95+
/**
96+
* The keys among `keys` that no workspace file row, legacy file row or file version names, live
97+
* or soft-deleted, in one query.
98+
*/
99+
async function keysWithoutFileRow(executor: DbOrTx, keys: readonly string[]): Promise<string[]> {
100+
if (keys.length === 0) return []
101+
const rows = await executor.execute<{ key: string }>(sql`
102+
SELECT candidate AS key
103+
FROM jsonb_array_elements_text(${JSON.stringify(keys)}::jsonb) AS candidate
104+
WHERE NOT EXISTS (SELECT 1 FROM ${workspaceFiles} WHERE ${workspaceFiles.key} = candidate)
105+
AND NOT EXISTS (SELECT 1 FROM ${workspaceFile} WHERE ${workspaceFile.key} = candidate)
106+
AND NOT EXISTS (
107+
SELECT 1 FROM ${workspaceFileVersion} WHERE ${workspaceFileVersion.key} = candidate
108+
)
109+
`)
110+
return rows.map((row) => row.key)
111+
}
112+
89113
/**
90114
* Collect all file storage keys for the given chat IDs from two sources:
91115
* 1. workspaceFiles rows with chatId FK (chat-scoped contexts only)
92-
* 2. fileAttachments[].key inside each copilot_messages.content: copilot keys, and
93-
* organization attachments (`assistant/<orgId>/…`) under their own context
116+
* 2. fileAttachments[].key inside each copilot_messages.content: copilot keys, organization
117+
* attachments (`assistant/<orgId>/…`) under their own context, and `workspace/<ws>/…` uploads
118+
* no file row names at all
94119
*
95120
* Each ref carries the scope whose chats may still reference it (see {@link ChatFileRef}).
96121
*
@@ -102,7 +127,10 @@ function fileAttachmentKeys(value: unknown): string[] {
102127
* file lifecycle), never by the message: a fork carries the same attachment when its copy
103128
* failed or its file was deleted, and the copilot bucket falls back to the workspace bucket
104129
* on GCS (and may be configured to it on S3), so deleting such a key as copilot storage
105-
* could delete a file another chat or the workspace still uses.
130+
* could delete a file another chat or the workspace still uses. Only once no row names the key
131+
* at all (a soft-deleted row can still be restored) does the message own it: a fork kept the
132+
* upload past its source chat, whose row cascaded away, so the last chat attaching it collects
133+
* it, and the reference check keeps it while another chat of that workspace still attaches it.
106134
*
107135
* Reads through the cleanup pool unless `executor` is given, which a deletion passes to read the
108136
* keys inside its transaction once it holds the chat rows, so no row naming a key can commit
@@ -177,9 +205,18 @@ export async function collectChatFiles(
177205
}
178206
}
179207

208+
const unownedCandidates = new Map<string, { chatId: string; workspaceId: string }>()
180209
for (const row of messageRows) {
181210
for (const key of fileAttachmentKeys(row.fileAttachments)) {
182211
if (seen.has(key)) continue
212+
const uploadWorkspaceId = key.startsWith('workspace/')
213+
? extractWorkspaceIdFromStorageKey(key)
214+
: null
215+
if (uploadWorkspaceId) {
216+
if (!unownedCandidates.has(key))
217+
unownedCandidates.set(key, { chatId: row.chatId, workspaceId: uploadWorkspaceId })
218+
continue
219+
}
183220
const organizationId = organizationAttachmentOwner(key)
184221
if (organizationId) {
185222
seen.add(key)
@@ -195,6 +232,18 @@ export async function collectChatFiles(
195232
}
196233
}
197234
}
235+
236+
for (const key of await keysWithoutFileRow(executor, [...unownedCandidates.keys()])) {
237+
const candidate = unownedCandidates.get(key)
238+
if (!candidate || seen.has(key)) continue
239+
seen.add(key)
240+
files.push({
241+
key,
242+
context: 'mothership',
243+
chatId: candidate.chatId,
244+
scope: { kind: 'workspace', id: candidate.workspaceId },
245+
})
246+
}
198247
}
199248

200249
return files

0 commit comments

Comments
 (0)