Skip to content

Commit d660542

Browse files
fix(tables): defer definition delete unless rows are provably drained
1 parent c1d69c3 commit d660542

4 files changed

Lines changed: 27 additions & 19 deletions

File tree

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const {
3636
mockDeleteFileMetadata: vi.fn(async () => true),
3737
mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })),
3838
mockDeleteRowsById: vi.fn(async () => ({ deleted: 0, failed: 0 })),
39-
mockDrainRowsByColumn: vi.fn(async () => ({ deleted: 0, budgetExhausted: false })),
39+
mockDrainRowsByColumn: vi.fn(async () => ({ deleted: 0, fullyDrained: false })),
4040
mockIsUsingCloudStorage: vi.fn(() => true),
4141
mockLimit,
4242
mockOrderBy,
@@ -186,7 +186,7 @@ describe('cleanup soft deletes — archived user tables', () => {
186186
})
187187

188188
it('drains rows before deleting the table definition', async () => {
189-
mockDrainRowsByColumn.mockResolvedValue({ deleted: 5, budgetExhausted: false })
189+
mockDrainRowsByColumn.mockResolvedValue({ deleted: 5, fullyDrained: true })
190190

191191
await runCleanupSoftDeletes(basePayload)
192192

@@ -200,8 +200,9 @@ describe('cleanup soft deletes — archived user tables', () => {
200200
)
201201
})
202202

203-
it('defers the definition delete when the row budget is exhausted', async () => {
204-
mockDrainRowsByColumn.mockResolvedValue({ deleted: 200_000, budgetExhausted: true })
203+
it('defers the definition delete when the drain does not fully complete', async () => {
204+
// Budget stop or a drain error both surface as fullyDrained: false.
205+
mockDrainRowsByColumn.mockResolvedValue({ deleted: 200_000, fullyDrained: false })
205206

206207
await runCleanupSoftDeletes(basePayload)
207208

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,9 @@ async function cleanupArchivedUserTables(
307307
})
308308
rowBudget -= drain.deleted
309309

310-
// Rows still remain — defer the definition delete so its cascade stays small.
311-
if (drain.budgetExhausted) break
310+
// Only delete the definition once its rows are provably gone. A budget stop
311+
// or a drain error leaves rows behind, so defer to keep the cascade small.
312+
if (!drain.fullyDrained) break
312313

313314
await db.delete(userTableDefinitions).where(eq(userTableDefinitions.id, tableId))
314315
definitionsDeleted++

apps/sim/lib/cleanup/batch-delete.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,44 +26,44 @@ describe('drainRowsByColumn', () => {
2626
vi.clearAllMocks()
2727
})
2828

29-
it('drains in batches until a short batch and reports the set exhausted', async () => {
29+
it('drains in batches until a short batch and reports the set fully drained', async () => {
3030
dbChainMockFns.returning
3131
.mockResolvedValueOnce(returnRows(2))
3232
.mockResolvedValueOnce(returnRows(1))
3333

3434
const result = await drainRowsByColumn({ ...baseOpts, batchSize: 2, rowBudget: 10 })
3535

36-
expect(result).toEqual({ deleted: 3, budgetExhausted: false })
36+
expect(result).toEqual({ deleted: 3, fullyDrained: true })
3737
expect(dbChainMockFns.returning).toHaveBeenCalledTimes(2)
3838
})
3939

40-
it('stops at the row budget and reports it exhausted', async () => {
40+
it('stops at the row budget and reports the set not fully drained', async () => {
4141
dbChainMockFns.returning
4242
.mockResolvedValueOnce(returnRows(2))
4343
.mockResolvedValueOnce(returnRows(2))
4444

4545
const result = await drainRowsByColumn({ ...baseOpts, batchSize: 2, rowBudget: 4 })
4646

47-
expect(result).toEqual({ deleted: 4, budgetExhausted: true })
47+
expect(result).toEqual({ deleted: 4, fullyDrained: false })
4848
expect(dbChainMockFns.returning).toHaveBeenCalledTimes(2)
4949
})
5050

51-
it('returns immediately when the match set is already empty', async () => {
51+
it('reports fully drained immediately when the match set is already empty', async () => {
5252
dbChainMockFns.returning.mockResolvedValueOnce([])
5353

5454
const result = await drainRowsByColumn({ ...baseOpts, batchSize: 2, rowBudget: 10 })
5555

56-
expect(result).toEqual({ deleted: 0, budgetExhausted: false })
56+
expect(result).toEqual({ deleted: 0, fullyDrained: true })
5757
expect(dbChainMockFns.returning).toHaveBeenCalledTimes(1)
5858
})
5959

60-
it('bails without throwing when a batch delete fails', async () => {
60+
it('reports not fully drained on a batch error so the caller defers the cascade', async () => {
6161
dbChainMockFns.returning
6262
.mockResolvedValueOnce(returnRows(2))
6363
.mockRejectedValueOnce(new Error('db down'))
6464

6565
const result = await drainRowsByColumn({ ...baseOpts, batchSize: 2, rowBudget: 10 })
6666

67-
expect(result).toEqual({ deleted: 2, budgetExhausted: false })
67+
expect(result).toEqual({ deleted: 2, fullyDrained: false })
6868
})
6969
})

apps/sim/lib/cleanup/batch-delete.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,13 @@ export interface DrainByColumnOptions {
278278

279279
export interface DrainResult {
280280
deleted: number
281-
/** True if the budget ran out before the match set was fully drained. */
282-
budgetExhausted: boolean
281+
/**
282+
* True only when the match set was confirmed empty (a short final batch).
283+
* Budget exhaustion and batch errors both yield `false` — callers must treat
284+
* `false` as "rows may remain" and defer any dependent parent-delete (whose
285+
* ON DELETE CASCADE would otherwise fire on the leftovers) to a later run.
286+
*/
287+
fullyDrained: boolean
283288
}
284289

285290
/**
@@ -316,15 +321,16 @@ export async function drainRowsByColumn({
316321
.returning({ id: idCol })
317322
} catch (error) {
318323
logger.error(`[${tableName}] Drain batch failed for ${matchValue}:`, { error })
319-
return { deleted, budgetExhausted: false }
324+
return { deleted, fullyDrained: false }
320325
}
321326

322327
deleted += batchDeleted.length
323328
remaining -= batchDeleted.length
324329

325330
// Short batch means the match set is exhausted.
326-
if (batchDeleted.length < limit) return { deleted, budgetExhausted: false }
331+
if (batchDeleted.length < limit) return { deleted, fullyDrained: true }
327332
}
328333

329-
return { deleted, budgetExhausted: true }
334+
// Hit the per-call budget on a full batch — rows may remain.
335+
return { deleted, fullyDrained: false }
330336
}

0 commit comments

Comments
 (0)