Skip to content

Commit b54e6a4

Browse files
committed
chore(cleanup): tighten the cleanup tests and comments
Drops tests the integration suites already cover, moves the non-string attachment key case into the PostgreSQL collect test, and makes the image checkpoint test finish chats after the slow one first so it proves the lowest-unfinished checkpoint. The tables room test reads its key from tableKeys.list. inlineChatImagePrefix returns null for an id outside the key grammar, which the event schema admits, so the image sweep skips and logs such an id instead of failing the event on every retry.
1 parent 5af9040 commit b54e6a4

11 files changed

Lines changed: 62 additions & 89 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room.test.tsx‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,6 @@ describe('useWorkspaceTablesRoom', () => {
4343
vi.useRealTimers()
4444
})
4545

46-
/*
47-
* The callback used to fire both invalidations and return nothing, so the room's delivery never
48-
* waited: a broadcast arriving while the list refetch was still loading invalidated it again,
49-
* which cancels and restarts the in-flight fetch. Under a burst of table writes a slow list never
50-
* finished loading.
51-
*/
5246
it('does not restart a list refetch still in flight when the next broadcast arrives', async () => {
5347
const pending: Array<() => void> = []
5448
const fetchList = vi.fn(
@@ -58,11 +52,11 @@ describe('useWorkspaceTablesRoom', () => {
5852
})
5953
)
6054
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
61-
queryClient.setQueryData([...tableKeys.lists(), 'ws-1'], [])
55+
queryClient.setQueryData(tableKeys.list('ws-1'), [])
6256

6357
function Probe() {
6458
useQuery({
65-
queryKey: [...tableKeys.lists(), 'ws-1'],
59+
queryKey: tableKeys.list('ws-1'),
6660
queryFn: fetchList,
6761
staleTime: Number.POSITIVE_INFINITY,
6862
})

‎apps/sim/lib/cleanup/chat-backend-cleanup-outbox.test.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,6 @@ describe('chat inline image cleanup', () => {
4040
mockSweepStoragePrefix.mockReset().mockResolvedValue('done')
4141
})
4242

43-
it('sweeps only the chats whose rows are gone', async () => {
44-
queueTableRows(copilotChats, [{ id: chatIds[1] }])
45-
46-
await expect(sweepImages({ chatIds: chatIds.slice(0, 3) }, outboxContext())).resolves.toBe(
47-
undefined
48-
)
49-
expect(sweptPrefixes().sort()).toEqual([prefixOf(chatIds[0]), prefixOf(chatIds[2])])
50-
})
51-
5243
it('sweeps several chats at once, never more than ten', async () => {
5344
queueTableRows(copilotChats, [])
5445
let inFlight = 0
@@ -71,6 +62,8 @@ describe('chat inline image cleanup', () => {
7162
const slow = chatIds[3]
7263
mockSweepStoragePrefix.mockImplementation(async (prefix: string) => {
7364
if (prefix !== prefixOf(slow)) return 'done'
65+
// Finishes after the chats behind it, so only "lowest unfinished" can yield 3 here.
66+
await new Promise((resolve) => setImmediate(resolve))
7467
deadline.near = true
7568
return 'continue'
7669
})
@@ -90,6 +83,15 @@ describe('chat inline image cleanup', () => {
9083
expect(sweptPrefixes().sort()).toEqual(chatIds.slice(3, 6).map(prefixOf))
9184
})
9285

86+
it('skips a chat id no image key can hold instead of failing the event on every retry', async () => {
87+
queueTableRows(copilotChats, [])
88+
89+
await expect(
90+
sweepImages({ chatIds: ['../escape', chatIds[0]] }, outboxContext())
91+
).resolves.toBe(undefined)
92+
expect(sweptPrefixes()).toEqual([prefixOf(chatIds[0])])
93+
})
94+
9395
it('spends an attempt when a chat’s listing stops advancing', async () => {
9496
queueTableRows(copilotChats, [])
9597
mockSweepStoragePrefix.mockImplementation(async (prefix: string) =>

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { db } from '@sim/db'
22
import { copilotChats } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
34
import { describeError, toError } from '@sim/utils/errors'
45
import { inArray } from 'drizzle-orm'
56
import {
@@ -23,6 +24,8 @@ import { inlineChatImagePrefix } from '@/lib/mothership/chat/inline-image-key'
2324
import { outboxDeadlineNear, sweepStoragePrefix } from '@/lib/uploads/core/prefix-sweep'
2425
import { deleteFile } from '@/lib/uploads/core/storage-service'
2526

27+
const logger = createLogger('ChatBackendCleanupOutbox')
28+
2629
/** Most object deletes, or chat prefix sweeps, one handler run has in flight at once. */
2730
const STORAGE_CONCURRENCY = 10
2831

@@ -45,6 +48,13 @@ async function sweepChatImages(
4548
context: OutboxEventContext
4649
): Promise<'done' | 'continue' | 'stalled'> {
4750
const prefix = inlineChatImagePrefix(chatId)
51+
if (!prefix) {
52+
logger.warn('Skipping an inline image sweep for a chat id no image key can hold', {
53+
eventId: context.eventId,
54+
chatId,
55+
})
56+
return 'done'
57+
}
4858
for (;;) {
4959
context.signal.throwIfAborted()
5060
if (outboxDeadlineNear(context)) return 'continue'

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

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

367-
it('keeps what an event queued before refs carried their scope cannot place', async () => {
367+
it('places a legacy upload ref by its workspace key, keeping one a remaining chat attaches', async () => {
368368
const ownerId = await seedUser()
369369
const workspaceId = await seedWorkspace(ownerId)
370370
const deletedChat = await seedChat(ownerId, workspaceId)
371371
const remainingChat = await seedChat(ownerId, workspaceId)
372372
const referenced = `workspace/${workspaceId}/1700000000000-abcd1234-kept.png`
373373
const unreferenced = `workspace/${workspaceId}/1700000000000-abcd1234-gone.png`
374-
const unplaceable = `copilot/${generateId()}/legacy.png`
375-
for (const key of [referenced, unreferenced, unplaceable]) await putObject(key)
374+
for (const key of [referenced, unreferenced]) await putObject(key)
376375
await db.insert(copilotMessages).values({
377376
chatId: remainingChat,
378377
messageId: generateId(),
@@ -392,19 +391,10 @@ describe('cleanup of a permanently deleted chat', () => {
392391
})),
393392
},
394393
})
395-
await db.insert(outboxEvent).values({
396-
id: generateId(),
397-
eventType: CHAT_BACKEND_CLEANUP_EVENT,
398-
payload: {
399-
chatIds: [],
400-
files: [{ key: unplaceable, context: 'copilot', chatId: deletedChat }],
401-
},
402-
})
403394

404-
expect((await deliverCleanups()).outcomes).toEqual(['completed', 'completed'])
395+
expect((await deliverCleanups()).outcomes).toEqual(['completed'])
405396
expect(await objectExists(referenced)).toBe(true)
406397
expect(await objectExists(unreferenced)).toBe(false)
407-
expect(await objectExists(unplaceable)).toBe(true)
408398
})
409399

410400
it('accepts the payloads queued before refs carried their scope', async () => {

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ const chatFileSchema = z
3636
])
3737
.nullable()
3838
.optional(),
39-
/** Legacy: an organization attachment's scope on events queued before refs carried `scope`. */
39+
/**
40+
* Legacy: an organization attachment's scope on events queued before refs carried `scope`.
41+
* Remove once those events have drained.
42+
*/
4043
organizationId: chatIdSchema.optional(),
4144
})
4245
.strict()
4346
.transform(({ organizationId, ...file }): ChatFileRef => {
44-
// Legacy: remove with `organizationId` once events queued before `scope` have drained.
4547
if (file.scope === undefined && organizationId)
4648
return { ...file, scope: { kind: 'organization', id: organizationId } }
4749
return file

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

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { db, dbFor } from '@sim/db'
33
import { copilotChats, copilotMessages, organization, user } from '@sim/db/schema'
44
import { generateId } from '@sim/utils/id'
55
import { compareStrings } from '@sim/utils/string'
6-
import { eq, inArray } from 'drizzle-orm'
7-
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
6+
import { eq } from 'drizzle-orm'
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
88
import { deletableChatFiles } from '@/lib/cleanup/chat-cleanup'
99
import { type ChatFileRef, collectChatFiles } from '@/lib/cleanup/chat-files'
1010

@@ -13,8 +13,7 @@ function attachment(key: string) {
1313
}
1414

1515
const userId = generateId()
16-
/** One organization with chats, and one whose only chats were the deleted ones. */
17-
const organizationIds = { withChats: generateId(), empty: generateId() }
16+
const organizationId = generateId()
1817

1918
beforeAll(async () => {
2019
const now = new Date()
@@ -26,20 +25,18 @@ beforeAll(async () => {
2625
createdAt: now,
2726
updatedAt: now,
2827
})
29-
await db.insert(organization).values(
30-
Object.values(organizationIds).map((id) => ({
31-
id,
32-
name: 'Chat cleanup fixture',
33-
slug: id,
34-
createdAt: now,
35-
updatedAt: now,
36-
}))
37-
)
28+
await db.insert(organization).values({
29+
id: organizationId,
30+
name: 'Chat cleanup fixture',
31+
slug: organizationId,
32+
createdAt: now,
33+
updatedAt: now,
34+
})
3835
})
3936

4037
afterAll(async () => {
4138
await db.delete(copilotChats).where(eq(copilotChats.userId, userId))
42-
await db.delete(organization).where(inArray(organization.id, Object.values(organizationIds)))
39+
await db.delete(organization).where(eq(organization.id, organizationId))
4340
await db.delete(user).where(eq(user.id, userId))
4441
await Promise.all([db.$client.end(), dbFor('cleanup').$client.end()])
4542
})
@@ -80,6 +77,7 @@ describe('chat cleanup file collection in PostgreSQL', () => {
8077
'not an attachment',
8178
null,
8279
{ key: '' },
80+
{ key: 42 },
8381
],
8482
},
8583
{
@@ -149,11 +147,11 @@ describe('deletable chat files against PostgreSQL message rows', () => {
149147
await seedChat(
150148
[{ role: 'user', content: 'fork', fileAttachments: [attachment(shared), 'x', { key: 7 }] }],
151149
[],
152-
organizationIds.withChats
150+
organizationId
153151
)
154152
// Attached only by a chat outside the scope, which cannot be a fork of the deleted one.
155153
await seedChat([{ role: 'user', content: 'other', fileAttachments: [attachment(elsewhere)] }])
156-
const scope = { kind: 'organization', id: organizationIds.withChats } as const
154+
const scope = { kind: 'organization', id: organizationId } as const
157155

158156
const deletable = await deletableChatFiles([
159157
ref(shared, scope),
@@ -166,27 +164,14 @@ describe('deletable chat files against PostgreSQL message rows', () => {
166164
)
167165
})
168166

169-
it('scans no messages for a scope with no chat left', async () => {
170-
const scan = vi.spyOn(dbFor('cleanup'), 'execute')
171-
const key = `copilot/${generateId()}/gone.png`
172-
173-
const deletable = await deletableChatFiles([
174-
ref(key, { kind: 'organization', id: organizationIds.empty }),
175-
])
176-
177-
expect(deletable.map((file) => file.key)).toEqual([key])
178-
expect(scan).not.toHaveBeenCalled()
179-
scan.mockRestore()
180-
})
181-
182167
it('places a legacy ref by its key, keeping one it cannot place', async () => {
183-
const referenced = `assistant/${organizationIds.withChats}/${userId}/u1/kept.png`
184-
const unreferenced = `assistant/${organizationIds.withChats}/${userId}/u2/gone.png`
168+
const referenced = `assistant/${organizationId}/${userId}/u1/kept.png`
169+
const unreferenced = `assistant/${organizationId}/${userId}/u2/gone.png`
185170
const unplaceable = `copilot/${generateId()}/legacy.png`
186171
await seedChat(
187172
[{ role: 'user', content: 'fork', fileAttachments: [attachment(referenced)] }],
188173
[],
189-
organizationIds.withChats
174+
organizationId
190175
)
191176

192177
const deletable = await deletableChatFiles(

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,4 @@ describe('chat purge storage', () => {
5252
])
5353
expect(deleted).toEqual({ copilot: ['copilot/1234/legacy.png'] })
5454
})
55-
56-
it('skips an attachment whose key is not a string instead of failing the purge', async () => {
57-
const deleted = await purge([
58-
{
59-
role: 'user',
60-
content: 'look',
61-
fileAttachments: [{ ...attachment('x'), key: 42 }, attachment('copilot/1234/kept.png')],
62-
},
63-
])
64-
expect(deleted).toEqual({ copilot: ['copilot/1234/kept.png'] })
65-
})
6655
})

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export function chatFileReferenceScope(
7171
const workspaceId = extractWorkspaceIdFromStorageKey(file.key)
7272
if (workspaceId) return { kind: 'workspace', id: workspaceId }
7373
// A fork copies the source's inline images under its own id, so the chat's own are unshared.
74-
if (file.key.startsWith(inlineChatImagePrefix(file.chatId))) return 'unshared'
74+
const ownImages = inlineChatImagePrefix(file.chatId)
75+
if (ownImages && file.key.startsWith(ownImages)) return 'unshared'
7576
return 'unknown'
7677
}
7778

‎apps/sim/lib/mothership/chat/inline-image-key.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@ import {
88
import { collectMarkdownImageSources } from '@/lib/mothership/chat/markdown-images'
99
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
1010

11-
/** The `chat-images/<chatId>/` prefix every inline image a chat published is stored under. */
12-
export function inlineChatImagePrefix(chatId: string): string {
13-
inlineImageRequestIdSchema.parse(chatId)
14-
return `${INLINE_CHAT_IMAGE_PREFIX}${chatId}/`
11+
function chatImagePrefix(validChatId: string): string {
12+
return `${INLINE_CHAT_IMAGE_PREFIX}${validChatId}/`
13+
}
14+
15+
/**
16+
* The `chat-images/<chatId>/` prefix every inline image a chat published is stored under, or null
17+
* for an id outside the key grammar, under which no image can have been stored.
18+
*/
19+
export function inlineChatImagePrefix(chatId: string): string | null {
20+
return inlineImageRequestIdSchema.safeParse(chatId).success ? chatImagePrefix(chatId) : null
1521
}
1622

1723
/** Stable per-message source identity makes replay independent of sandbox lifetime. */
1824
export function inlineChatImageKey(chatId: string, requestId: string, reference: string): string {
25+
const prefix = chatImagePrefix(inlineImageRequestIdSchema.parse(chatId))
1926
inlineImageRequestIdSchema.parse(requestId)
2027
const digest = createHash('sha256').update(normalizeInlineFileReference(reference)).digest('hex')
21-
return `${inlineChatImagePrefix(chatId)}${requestId}/${digest}.webp`
28+
return `${prefix}${requestId}/${digest}.webp`
2229
}
2330

2431
/**

‎apps/sim/lib/uploads/core/prefix-sweep.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { sweepStoragePrefix } from '@/lib/uploads/core/prefix-sweep'
1717
const outbox = { signal: new AbortController().signal }
1818
const deletedKeys = () => storageServiceMockFns.mockDeleteFile.mock.calls.map(([{ key }]) => key)
1919

20-
/** A provider (Azure Blob, GCS, S3-compatibles) may return a short or empty page that is not the last. */
2120
describe('sweepStoragePrefix over a provider listing', () => {
2221
beforeEach(() => {
2322
setUploadsConfig({ USE_S3_STORAGE: true, S3_CONFIG: { bucket: 'bucket', region: 'r' } })

0 commit comments

Comments
 (0)