@@ -5,16 +5,19 @@ import { task } from '@trigger.dev/sdk'
55import { sql } from 'drizzle-orm'
66import { asOrchestrationError } from '@/lib/core/orchestration/types'
77import { getColumnId } from '@/lib/table/column-keys'
8+ import { getDeleteSnapshotBatchSize } from '@/lib/table/constants'
89import { signalTableRowsChanged } from '@/lib/table/events'
910import { assertRowDelete , TableLockedError } from '@/lib/table/mutation-locks'
1011import type { DbTransaction } from '@/lib/table/planner'
12+ import type { DeletedTableRow } from '@/lib/table/rows/ordering'
1113import { withLockedTable } from '@/lib/table/service'
14+ import { fireTableTrigger } from '@/lib/table/trigger'
1215import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
16+ import type { RowData , TableSchema } from '@/lib/table/types'
1317
1418const logger = createLogger ( 'CleanupTableRowTtl' )
1519const cleanupDb = dbFor ( 'cleanup' )
1620
17- const TTL_CLEANUP_BATCH_SIZE = 500
1821const TTL_CLEANUP_MAX_BATCHES = 100
1922
2023interface ExpiredTtlTableRef {
@@ -23,12 +26,20 @@ interface ExpiredTtlTableRef {
2326 workspaceId : string
2427}
2528
26- interface DeletedTtlBatch {
27- attempted : boolean
29+ interface DeletedTtlRows {
2830 deleted : number
2931 cursor : TtlCleanupCursor | null
32+ rows : DeletedTableRow [ ]
3033}
3134
35+ type DeletedTtlBatch =
36+ | { attempted : false ; deleted : 0 ; cursor : null }
37+ | ( DeletedTtlRows & {
38+ attempted : true
39+ tableName : string
40+ schema : TableSchema
41+ } )
42+
3243interface TtlCleanupCursor {
3344 createdAt : string
3445 id : string
@@ -85,34 +96,30 @@ async function listExpiredTtlTables(nowEpochSeconds: number): Promise<ExpiredTtl
8596 return Array . isArray ( rows ) ? rows : [ ]
8697}
8798
88- function parseDeletedBatch ( rows : unknown ) : Omit < DeletedTtlBatch , 'attempted' > {
89- const [ row ] = Array . isArray ( rows )
90- ? ( rows as Array < {
91- count ?: number | string
92- createdAt ?: string | null
93- lastId ?: string | null
94- } > )
95- : [ ]
96- if ( ! row ) throw new Error ( 'Table row TTL cleanup did not return a deleted count' )
97-
98- const deleted = Number ( row . count )
99- if ( ! Number . isSafeInteger ( deleted ) || deleted < 0 || deleted > TTL_CLEANUP_BATCH_SIZE ) {
99+ function parseDeletedBatch ( rows : unknown , batchSize : number ) : DeletedTtlRows {
100+ if ( ! Array . isArray ( rows ) ) {
101+ throw new Error ( 'Table row TTL cleanup did not return deleted rows' )
102+ }
103+ const deletedRows = rows as Array < { id ?: unknown ; data ?: unknown ; createdAt ?: unknown } >
104+ if ( deletedRows . length > batchSize ) {
100105 throw new Error ( 'Table row TTL cleanup returned an invalid deleted count' )
101106 }
102- if ( deleted > 0 ) {
103- if ( typeof row . lastId !== 'string' ) {
107+ const parsed = deletedRows . map ( ( row ) => {
108+ if ( typeof row . id !== 'string' ) {
104109 throw new Error ( 'Table row TTL cleanup did not return a row cursor' )
105110 }
106111 if ( typeof row . createdAt !== 'string' ) {
107112 throw new Error ( 'Table row TTL cleanup did not return a creation-time cursor' )
108113 }
109- }
114+ return {
115+ cursor : { createdAt : row . createdAt , id : row . id } ,
116+ row : { id : row . id , data : row . data as RowData } ,
117+ }
118+ } )
110119 return {
111- deleted,
112- cursor :
113- typeof row . createdAt === 'string' && typeof row . lastId === 'string'
114- ? { createdAt : row . createdAt , id : row . lastId }
115- : null ,
120+ deleted : parsed . length ,
121+ cursor : parsed [ parsed . length - 1 ] ?. cursor ?? null ,
122+ rows : parsed . map ( ( { row } ) => row ) ,
116123 }
117124}
118125
@@ -122,13 +129,10 @@ async function deleteExpiredTableRowBatch(
122129 workspaceId : string ,
123130 columnKey : string ,
124131 nowEpochSeconds : number ,
132+ batchSize : number ,
125133 after ?: TtlCleanupCursor
126- ) : Promise < Omit < DeletedTtlBatch , 'attempted' > > {
127- const rows = await trx . execute < {
128- count : number | string
129- createdAt : string | null
130- lastId : string | null
131- } > ( sql `
134+ ) : Promise < DeletedTtlRows > {
135+ const rows = await trx . execute < { id : string ; data : RowData ; createdAt : string } > ( sql `
132136 WITH candidates AS MATERIALIZED (
133137 SELECT table_row.id
134138 FROM ${ userTableRows } AS table_row
@@ -142,37 +146,34 @@ async function deleteExpiredTableRowBatch(
142146 AND jsonb_typeof(table_row.data->${ columnKey } ) = 'number'
143147 AND (table_row.data->>${ columnKey } )::numeric <= ${ nowEpochSeconds }
144148 ORDER BY table_row.created_at, table_row.id
145- LIMIT ${ TTL_CLEANUP_BATCH_SIZE }
149+ LIMIT ${ batchSize }
146150 FOR UPDATE OF table_row SKIP LOCKED
147151 ), deleted AS (
148152 DELETE FROM ${ userTableRows } AS table_row
149153 USING candidates
150154 WHERE table_row.id = candidates.id
151- RETURNING table_row.id, table_row.created_at
155+ RETURNING
156+ table_row.id,
157+ table_row.data,
158+ to_char(table_row.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS "createdAt"
152159 )
153- SELECT
154- count(*)::integer AS count,
155- (array_agg(id ORDER BY created_at DESC, id DESC))[1] AS "lastId",
156- (
157- array_agg(
158- to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US')
159- ORDER BY created_at DESC, id DESC
160- )
161- )[1] AS "createdAt"
160+ SELECT id, data, "createdAt"
162161 FROM deleted
162+ ORDER BY "createdAt", id
163163 ` )
164- return parseDeletedBatch ( rows )
164+ return parseDeletedBatch ( rows , batchSize )
165165}
166166
167167async function deleteExpiredRowsForTable (
168168 ref : ExpiredTtlTableRef ,
169169 nowEpochSeconds : number ,
170+ batchSize : number ,
170171 after ?: TtlCleanupCursor
171172) : Promise < DeletedTtlBatch > {
172173 try {
173- return await withLockedTable (
174+ const batch = await withLockedTable (
174175 ref . id ,
175- async ( table , trx ) => {
176+ async ( table , trx ) : Promise < DeletedTtlBatch > => {
176177 try {
177178 assertRowDelete ( table )
178179 } catch ( error ) {
@@ -191,12 +192,31 @@ async function deleteExpiredRowsForTable(
191192 table . workspaceId ,
192193 getColumnId ( ttlColumn ) ,
193194 nowEpochSeconds ,
195+ batchSize ,
194196 after
195197 )
196- return { attempted : true , ...batch }
198+ return {
199+ attempted : true ,
200+ ...batch ,
201+ tableName : table . name ,
202+ schema : table . schema ,
203+ } satisfies DeletedTtlBatch
197204 } ,
198205 { expectedWorkspaceId : ref . workspaceId }
199206 )
207+ if ( batch . attempted && batch . rows . length > 0 ) {
208+ await fireTableTrigger (
209+ ref . id ,
210+ ref . workspaceId ,
211+ batch . tableName ,
212+ 'delete' ,
213+ batch . rows ,
214+ null ,
215+ batch . schema ,
216+ 'ttl-cleanup'
217+ )
218+ }
219+ return batch
200220 } catch ( error ) {
201221 if ( asOrchestrationError ( error ) ?. code === 'not_found' ) {
202222 return { attempted : false , deleted : 0 , cursor : null }
@@ -216,6 +236,7 @@ export async function runCleanupTableRowTtl(
216236 }
217237
218238 const nowEpochSeconds = Math . floor ( Date . now ( ) / 1000 )
239+ const batchSize = getDeleteSnapshotBatchSize ( )
219240 const tableRefs = await listExpiredTtlTables ( nowEpochSeconds )
220241 const tableStates : TtlTableCleanupState [ ] = tableRefs . map ( ( ref ) => ( {
221242 ref,
@@ -234,7 +255,12 @@ export async function runCleanupTableRowTtl(
234255 if ( state . complete ) continue
235256 if ( batches === TTL_CLEANUP_MAX_BATCHES || signal ?. aborted ) break
236257
237- const batch = await deleteExpiredRowsForTable ( state . ref , nowEpochSeconds , state . after )
258+ const batch = await deleteExpiredRowsForTable (
259+ state . ref ,
260+ nowEpochSeconds ,
261+ batchSize ,
262+ state . after
263+ )
238264 if ( ! batch . attempted ) {
239265 state . complete = true
240266 continue
@@ -244,7 +270,7 @@ export async function runCleanupTableRowTtl(
244270 deleted += batch . deleted
245271 state . deleted += batch . deleted
246272 state . after = batch . cursor ?? undefined
247- if ( batch . deleted < TTL_CLEANUP_BATCH_SIZE ) state . complete = true
273+ if ( batch . deleted < batchSize ) state . complete = true
248274 }
249275 }
250276
0 commit comments