@@ -2,8 +2,14 @@ import { db } from '@sim/db'
22import { copilotChats } from '@sim/db/schema'
33import { describeError , toError } from '@sim/utils/errors'
44import { inArray } from 'drizzle-orm'
5- import { chatBackendCleanupPayloadSchema } from '@/lib/cleanup/chat-backend-cleanup'
6- import { CHAT_BACKEND_CLEANUP_EVENT } from '@/lib/cleanup/chat-backend-cleanup-event'
5+ import {
6+ chatBackendCleanupPayloadSchema ,
7+ chatInlineImageCleanupPayloadSchema ,
8+ } from '@/lib/cleanup/chat-backend-cleanup'
9+ import {
10+ CHAT_BACKEND_CLEANUP_EVENT ,
11+ CHAT_INLINE_IMAGE_CLEANUP_EVENT ,
12+ } from '@/lib/cleanup/chat-backend-cleanup-event'
713import { cleanupCopilotBackend , deletableChatFiles } from '@/lib/cleanup/chat-cleanup'
814import { env } from '@/lib/core/config/env'
915import {
@@ -13,80 +19,57 @@ import {
1319 type OutboxHandlerRegistry ,
1420} from '@/lib/core/outbox/service'
1521import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
16- import {
17- INLINE_CHAT_IMAGE_PREFIX ,
18- inlineImageRequestIdSchema ,
19- } from '@/lib/mothership/chat/inline-image-reference'
22+ import { inlineChatImagePrefix } from '@/lib/mothership/chat/inline-image-key'
2023import { outboxDeadlineNear , sweepStoragePrefix } from '@/lib/uploads/core/prefix-sweep'
2124import { deleteFile } from '@/lib/uploads/core/storage-service'
2225
23- const FILE_DELETE_CONCURRENCY = 10
26+ /** Most object deletes, or chat prefix sweeps, one handler run has in flight at once. */
27+ const STORAGE_CONCURRENCY = 10
28+
29+ /** The ids among `chatIds` that still have a `copilot_chats` row. */
30+ async function survivingChatIds ( chatIds : readonly string [ ] ) : Promise < Set < string > > {
31+ if ( chatIds . length === 0 ) return new Set ( )
32+ const rows = await db
33+ . select ( { id : copilotChats . id } )
34+ . from ( copilotChats )
35+ . where ( inArray ( copilotChats . id , [ ...chatIds ] ) )
36+ return new Set ( rows . map ( ( { id } ) => id ) )
37+ }
2438
2539/**
26- * Deletes every inline image under each deleted chat's `chat-images/<chatId>/` prefix, from the
27- * `from`th chat on. `swept` is how many leading chats are done: all of them unless the deadline
28- * neared (`continue`) or a listing stopped advancing (`stalled`).
40+ * Sweeps `chatId`'s inline image prefix until it is empty, the deadline nears (`continue`), or a
41+ * listing stops advancing (`stalled`).
2942 */
30- async function sweepInlineImages (
31- chatIds : readonly string [ ] ,
32- deleted : ReadonlySet < string > ,
33- from : number ,
43+ async function sweepChatImages (
44+ chatId : string ,
3445 context : OutboxEventContext
35- ) : Promise < { swept : number ; outcome : 'done' | 'continue' | 'stalled' } > {
36- for ( let index = from ; index < chatIds . length ; index ++ ) {
37- const chatId = chatIds [ index ]
38- // A chat id outside the image key grammar never had an image stored under it.
39- if ( ! deleted . has ( chatId ) || ! inlineImageRequestIdSchema . safeParse ( chatId ) . success ) continue
40- for ( ; ; ) {
41- context . signal . throwIfAborted ( )
42- const outcome = await sweepStoragePrefix (
43- `${ INLINE_CHAT_IMAGE_PREFIX } ${ chatId } /` ,
44- 'mothership' ,
45- context
46- )
47- if ( outcome === 'done' ) break
48- if ( outcome === 'stalled' || outboxDeadlineNear ( context ) ) return { swept : index , outcome }
49- }
50- if ( outboxDeadlineNear ( context ) ) return { swept : index + 1 , outcome : 'continue' }
46+ ) : Promise < 'done' | 'continue' | 'stalled' > {
47+ const prefix = inlineChatImagePrefix ( chatId )
48+ for ( ; ; ) {
49+ context . signal . throwIfAborted ( )
50+ if ( outboxDeadlineNear ( context ) ) return 'continue'
51+ const outcome = await sweepStoragePrefix ( prefix , 'mothership' , context )
52+ if ( outcome !== 'continue' ) return outcome
5153 }
52- return { swept : chatIds . length , outcome : 'done' }
5354}
5455
5556export const chatBackendCleanupOutboxHandlers = {
5657 /**
57- * Purges the inline images, copilot backend data and stored files of chats whose rows are gone.
58- * A chat whose row survived (its delete was spared, rolled back, or never happened) keeps all
59- * three. Inline images go by the chat's prefix rather than by key, so one a turn published after
60- * the delete read the transcript goes too; the sweep checkpoints how many chats it finished and
61- * continues in a later run once its deadline nears. Any failure throws so the outbox retries it:
62- * a failed backend call, a failed object delete, or chat ids queued for the backend reaching a
63- * processor without `COPILOT_API_KEY`. An object already gone counts as deleted, so a retry
64- * repeats only what is left. A stored file another chat of its scope still references, as a
65- * fork's uncopied attachment does, is kept ({@link deletableChatFiles}).
58+ * Purges the copilot backend data and stored files of chats whose rows are gone; a surviving
59+ * chat keeps both, and a file another chat of its scope still references is kept
60+ * ({@link deletableChatFiles}). Any failure throws so the outbox retries; an object already gone
61+ * counts as deleted.
6662 */
6763 [ CHAT_BACKEND_CLEANUP_EVENT ] : async ( rawPayload , context ) => {
68- const { chatIds, files, copilotBackend, imagesSwept } =
69- chatBackendCleanupPayloadSchema . parse ( rawPayload )
64+ const { chatIds, files } = chatBackendCleanupPayloadSchema . parse ( rawPayload )
7065 context . signal . throwIfAborted ( )
71- const named = [ ...new Set ( [ ...chatIds , ...files . map ( ( file ) => file . chatId ) ] ) ]
72- const surviving = await db
73- . select ( { id : copilotChats . id } )
74- . from ( copilotChats )
75- . where ( inArray ( copilotChats . id , named ) )
76- const survivingIds = new Set ( surviving . map ( ( { id } ) => id ) )
66+ const survivingIds = await survivingChatIds ( [
67+ ...new Set ( [ ...chatIds , ...files . map ( ( file ) => file . chatId ) ] ) ,
68+ ] )
7769 const deletedChatIds = chatIds . filter ( ( id ) => ! survivingIds . has ( id ) )
7870 const deletedFiles = files . filter ( ( file ) => ! survivingIds . has ( file . chatId ) )
7971
80- if ( imagesSwept < chatIds . length ) {
81- const sweep = await sweepInlineImages ( chatIds , new Set ( deletedChatIds ) , imagesSwept , context )
82- if ( sweep . swept > imagesSwept ) await context . checkpointPayload ( { imagesSwept : sweep . swept } )
83- if ( sweep . outcome === 'continue' )
84- return continueOutboxHandler ( 'Continuing chat inline image cleanup' )
85- if ( sweep . outcome === 'stalled' )
86- return deferOutboxHandler ( 'Chat inline image cleanup removed nothing from its listing' )
87- }
88-
89- if ( copilotBackend && deletedChatIds . length > 0 ) {
72+ if ( deletedChatIds . length > 0 ) {
9073 context . signal . throwIfAborted ( )
9174 if ( ! env . COPILOT_API_KEY ) throw new Error ( 'Copilot cleanup is not configured' )
9275 const result = await cleanupCopilotBackend (
@@ -99,7 +82,7 @@ export const chatBackendCleanupOutboxHandlers = {
9982 if ( deletedFiles . length === 0 ) return
10083 const failures = await mapWithConcurrency (
10184 await deletableChatFiles ( deletedFiles ) ,
102- FILE_DELETE_CONCURRENCY ,
85+ STORAGE_CONCURRENCY ,
10386 async ( { key, context : storageContext } ) => {
10487 context . signal . throwIfAborted ( )
10588 try {
@@ -112,4 +95,26 @@ export const chatBackendCleanupOutboxHandlers = {
11295 const failure = failures . find ( ( error ) => error !== undefined )
11396 if ( failure ) throw failure
11497 } ,
98+
99+ /**
100+ * Deletes everything under the `chat-images/<chatId>/` prefix of each chat whose row is gone, so
101+ * an image a turn published after the delete read the transcript goes too. Sweeps several chats
102+ * at once, checkpoints how many leading chats are finished, and continues in a later run once
103+ * the deadline nears.
104+ */
105+ [ CHAT_INLINE_IMAGE_CLEANUP_EVENT ] : async ( rawPayload , context ) => {
106+ const { chatIds, swept } = chatInlineImageCleanupPayloadSchema . parse ( rawPayload )
107+ context . signal . throwIfAborted ( )
108+ const pending = chatIds . slice ( swept )
109+ const survivingIds = await survivingChatIds ( pending )
110+ const outcomes = await mapWithConcurrency ( pending , STORAGE_CONCURRENCY , ( chatId ) =>
111+ survivingIds . has ( chatId ) ? Promise . resolve ( 'done' as const ) : sweepChatImages ( chatId , context )
112+ )
113+ const unfinished = outcomes . findIndex ( ( outcome ) => outcome !== 'done' )
114+ if ( unfinished === - 1 ) return
115+ if ( unfinished > 0 ) await context . checkpointPayload ( { swept : swept + unfinished } )
116+ if ( outcomes . includes ( 'continue' ) )
117+ return continueOutboxHandler ( 'Continuing chat inline image cleanup' )
118+ return deferOutboxHandler ( 'Chat inline image cleanup removed nothing from its listing' )
119+ } ,
115120} satisfies OutboxHandlerRegistry
0 commit comments