Skip to content

Commit 1a8784f

Browse files
feat(db): role-keyed dbFor clients for cleanup and exec workloads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNy9Fzpfc1FdHAzYyb6Ycy
1 parent daa2416 commit 1a8784f

14 files changed

Lines changed: 188 additions & 67 deletions

File tree

apps/sim/background/cleanup-logs.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,17 @@ const {
8080
}
8181
})
8282

83-
vi.mock('@sim/db', () => ({
84-
db: {
83+
vi.mock('@sim/db', () => {
84+
const db = {
8585
execute: mockExecute,
8686
select: mockSelect,
87-
},
88-
}))
87+
}
88+
return {
89+
db,
90+
// Cleanup-pool client shares the instance so the seeded chains still apply.
91+
dbFor: () => db,
92+
}
93+
})
8994

9095
vi.mock('@sim/db/schema', () => ({
9196
executionLargeValueDependencies: {

apps/sim/background/cleanup-logs.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { db } from '@sim/db'
1+
import { dbFor } from '@sim/db'
22
import {
33
executionLargeValueDependencies,
44
executionLargeValueReferences,
@@ -30,6 +30,9 @@ import { deleteFileMetadata } from '@/lib/uploads/server/metadata'
3030

3131
const logger = createLogger('CleanupLogs')
3232

33+
/** All cleanup queries run on the dedicated cleanup pool. */
34+
const cleanupDb = dbFor('cleanup')
35+
3336
interface FileDeleteStats {
3437
filesTotal: number
3538
filesDeleted: number
@@ -153,7 +156,7 @@ async function cleanupLargeExecutionValues(
153156
LARGE_VALUE_CLEANUP_BATCH_SIZE,
154157
LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted
155158
)
156-
const rows = await db
159+
const rows = await cleanupDb
157160
.select({ key: executionLargeValues.key })
158161
.from(executionLargeValues)
159162
.where(
@@ -219,7 +222,7 @@ async function cleanupLegacyLargeExecutionValues(
219222
LARGE_VALUE_CLEANUP_BATCH_SIZE,
220223
LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted
221224
)
222-
const rows = await db
225+
const rows = await cleanupDb
223226
.select({ key: workspaceFiles.key })
224227
.from(workspaceFiles)
225228
.where(
@@ -377,8 +380,9 @@ async function cleanupWorkflowExecutionLogs(
377380
tableDef: workflowExecutionLogs,
378381
workspaceIds,
379382
tableName: `${label}/workflow_execution_logs`,
383+
dbClient: cleanupDb,
380384
selectChunk: (chunkIds, limit) =>
381-
db
385+
cleanupDb
382386
.select({
383387
id: workflowExecutionLogs.id,
384388
files: workflowExecutionLogs.files,
@@ -465,6 +469,7 @@ export async function runCleanupLogs(payload: CleanupJobPayload): Promise<void>
465469
workspaceIds,
466470
retentionDate,
467471
tableName: `${label}/job_execution_logs`,
472+
dbClient: cleanupDb,
468473
})
469474

470475
if (runGlobalHousekeeping && plan === 'free') {

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,18 @@ const {
6666
}
6767
})
6868

69-
vi.mock('@sim/db', () => ({
70-
db: {
69+
vi.mock('@sim/db', () => {
70+
const db = {
7171
delete: mockDelete,
7272
select: mockSelect,
7373
transaction: mockTransaction,
74-
},
75-
}))
74+
}
75+
return {
76+
db,
77+
// Cleanup-pool client shares the instance so the seeded chains still apply.
78+
dbFor: () => db,
79+
}
80+
})
7681

7782
vi.mock('@sim/db/schema', () => {
7883
const table = (cols: string[]) =>

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { db } from '@sim/db'
1+
import { db, dbFor } from '@sim/db'
22
import {
33
copilotChats,
44
document,
@@ -37,6 +37,13 @@ import { deleteFileMetadata } from '@/lib/uploads/server/metadata'
3737

3838
const logger = createLogger('CleanupSoftDeletes')
3939

40+
/**
41+
* Cleanup queries run on the dedicated cleanup pool. The one exception is the
42+
* billable-file transaction below, which couples row deletion with a storage
43+
* billing decrement — billing writes stay on the default client.
44+
*/
45+
const cleanupDb = dbFor('cleanup')
46+
4047
const KB_ORPHAN_BINDING_BATCH_SIZE = 500
4148
const KB_ORPHAN_BINDING_TOTAL_LIMIT = 5_000
4249
/**
@@ -81,7 +88,7 @@ async function selectExpiredWorkspaceFiles(
8188
): Promise<WorkspaceFileScope> {
8289
const [legacyRows, multiContextRows] = await Promise.all([
8390
selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
84-
db
91+
cleanupDb
8592
.select({
8693
id: workspaceFile.id,
8794
key: workspaceFile.key,
@@ -98,7 +105,7 @@ async function selectExpiredWorkspaceFiles(
98105
.limit(chunkLimit)
99106
),
100107
selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
101-
db
108+
cleanupDb
102109
.select({
103110
id: workspaceFiles.id,
104111
key: workspaceFiles.key,
@@ -200,7 +207,7 @@ async function deleteExpiredLegacyWorkspaceFileRows(
200207
const result = { deleted: 0, failed: 0 }
201208
for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) {
202209
try {
203-
const deleted = await db
210+
const deleted = await cleanupDb
204211
.delete(workspaceFile)
205212
.where(
206213
and(
@@ -240,7 +247,7 @@ async function deleteExpiredUnbilledWorkspaceFileRows(
240247
for (const [context, contextRows] of rowsByContext) {
241248
for (const batch of chunkArray(contextRows, DEFAULT_DELETE_CHUNK_SIZE)) {
242249
try {
243-
const deleted = await db
250+
const deleted = await cleanupDb
244251
.delete(workspaceFiles)
245252
.where(
246253
and(
@@ -343,7 +350,7 @@ async function hardDeleteKnowledgeBaseDocuments(
343350
label: string
344351
): Promise<void> {
345352
for (let batch = 0; batch < KB_DOCUMENT_DELETE_MAX_BATCHES; batch++) {
346-
const documentRows = await db
353+
const documentRows = await cleanupDb
347354
.select({ id: document.id })
348355
.from(document)
349356
.where(inArray(document.knowledgeBaseId, knowledgeBaseIds))
@@ -358,7 +365,7 @@ async function hardDeleteKnowledgeBaseDocuments(
358365
}
359366
}
360367

361-
const remaining = await db
368+
const remaining = await cleanupDb
362369
.select({ id: document.id })
363370
.from(document)
364371
.where(inArray(document.knowledgeBaseId, knowledgeBaseIds))
@@ -378,8 +385,9 @@ async function cleanupExpiredKnowledgeBases(
378385
workspaceIds,
379386
tableName: `${label}/knowledgeBase`,
380387
batchSize: KB_RETENTION_BATCH_SIZE,
388+
dbClient: cleanupDb,
381389
selectChunk: (chunkIds, limit) =>
382-
db
390+
cleanupDb
383391
.select({ id: knowledgeBase.id })
384392
.from(knowledgeBase)
385393
.where(
@@ -458,7 +466,7 @@ async function cleanupOrphanedKnowledgeBaseBindings(
458466
KB_ORPHAN_BINDING_BATCH_SIZE,
459467
KB_ORPHAN_BINDING_TOTAL_LIMIT - attempted
460468
)
461-
const rows = await db
469+
const rows = await cleanupDb
462470
.select({ key: workspaceFiles.key })
463471
.from(workspaceFiles)
464472
.where(
@@ -535,7 +543,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
535543
// different subsets above the LIMIT cap and orphan or prematurely purge data.
536544
const [doomedWorkflows, fileScope] = await Promise.all([
537545
selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
538-
db
546+
cleanupDb
539547
.select({ id: workflow.id })
540548
.from(workflow)
541549
.where(
@@ -555,7 +563,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
555563

556564
if (doomedWorkflowIds.length > 0) {
557565
const doomedChats = await selectRowsByIdChunks(doomedWorkflowIds, (chunkIds, chunkLimit) =>
558-
db
566+
cleanupDb
559567
.select({ id: copilotChats.id })
560568
.from(copilotChats)
561569
.where(inArray(copilotChats.workflowId, chunkIds))
@@ -577,7 +585,8 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
577585
workflow,
578586
workflow.id,
579587
doomedWorkflowIds,
580-
`${label}/workflow`
588+
`${label}/workflow`,
589+
cleanupDb
581590
)
582591
totalDeleted += workflowResult.deleted
583592

@@ -614,6 +623,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
614623
retentionDate,
615624
tableName: `${label}/${target.name}`,
616625
requireTimestampNotNull: true,
626+
dbClient: cleanupDb,
617627
})
618628
totalDeleted += result.deleted
619629
}

apps/sim/background/cleanup-tasks.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { db } from '@sim/db'
1+
import { dbFor } from '@sim/db'
22
import {
33
copilotAsyncToolCalls,
44
copilotChats,
@@ -21,6 +21,9 @@ import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup'
2121

2222
const logger = createLogger('CleanupTasks')
2323

24+
/** All cleanup queries run on the dedicated cleanup pool. */
25+
const cleanupDb = dbFor('cleanup')
26+
2427
/**
2528
* Delete copilot run checkpoints and async tool calls via join through copilotRuns.
2629
* These tables don't have a direct workspaceId — we find qualifying run IDs first.
@@ -46,7 +49,7 @@ async function cleanupRunChildren(
4649
if (workspaceIds.length === 0) return []
4750

4851
const runIds = await selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
49-
db
52+
cleanupDb
5053
.select({ id: copilotRuns.id })
5154
.from(copilotRuns)
5255
.where(
@@ -62,7 +65,9 @@ async function cleanupRunChildren(
6265
const ids = runIds.map((r) => r.id)
6366

6467
return Promise.all(
65-
RUN_CHILD_TABLES.map((t) => deleteRowsById(t.table, t.runIdCol, ids, `${label}/${t.name}`))
68+
RUN_CHILD_TABLES.map((t) =>
69+
deleteRowsById(t.table, t.runIdCol, ids, `${label}/${t.name}`, cleanupDb)
70+
)
6671
)
6772
}
6873

@@ -81,7 +86,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise<void>
8186
)
8287

8388
const doomedChats = await selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) =>
84-
db
89+
cleanupDb
8590
.select({ id: copilotChats.id })
8691
.from(copilotChats)
8792
.where(
@@ -106,7 +111,8 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise<void>
106111
copilotFeedback,
107112
copilotFeedback.chatId,
108113
doomedChatIds,
109-
`${label}/copilotFeedback`
114+
`${label}/copilotFeedback`,
115+
cleanupDb
110116
)
111117

112118
// Delete copilot runs (has workspaceId directly, cascades checkpoints)
@@ -117,6 +123,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise<void>
117123
workspaceIds,
118124
retentionDate,
119125
tableName: `${label}/copilotRuns`,
126+
dbClient: cleanupDb,
120127
})
121128

122129
// Delete copilot chats using the exact IDs collected above so the chat
@@ -125,7 +132,8 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise<void>
125132
copilotChats,
126133
copilotChats.id,
127134
doomedChatIds,
128-
`${label}/copilotChats`
135+
`${label}/copilotChats`,
136+
cleanupDb
129137
)
130138

131139
// Delete mothership inbox tasks (has workspaceId directly)
@@ -136,6 +144,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise<void>
136144
workspaceIds,
137145
retentionDate,
138146
tableName: `${label}/mothershipInboxTask`,
147+
dbClient: cleanupDb,
139148
})
140149

141150
const totalDeleted =

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import type { PgColumn, PgTable } from 'drizzle-orm/pg-core'
55

66
const logger = createLogger('BatchDelete')
77

8+
/**
9+
* Structural client surface the delete helpers need. Satisfied by the global
10+
* `db`, a `dbFor(...)` sub-pool client, and a transaction handle, so callers
11+
* pick which pool the deletes run on (cleanup jobs pass `dbFor('cleanup')`).
12+
*/
13+
export type BatchDeleteClient = Pick<typeof db, 'select' | 'delete'>
14+
815
export const DEFAULT_BATCH_SIZE = 2000
916
/** 50 × 2000 = 100K row cap per cleanup run; drains long-tail tenants in days, not weeks. */
1017
export const DEFAULT_MAX_BATCHES_PER_TABLE = 50
@@ -84,6 +91,8 @@ export interface ChunkedBatchDeleteOptions<TRow extends { id: string }> {
8491
*/
8592
totalRowLimit?: number
8693
workspaceChunkSize?: number
94+
/** Client the DELETEs run on. Defaults to the global pool. */
95+
dbClient?: BatchDeleteClient
8796
}
8897

8998
/**
@@ -107,6 +116,7 @@ export async function chunkedBatchDelete<TRow extends { id: string }>({
107116
maxBatches = DEFAULT_MAX_BATCHES_PER_TABLE,
108117
totalRowLimit = DEFAULT_BATCH_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE,
109118
workspaceChunkSize = DEFAULT_WORKSPACE_CHUNK_SIZE,
119+
dbClient = db,
110120
}: ChunkedBatchDeleteOptions<TRow>): Promise<TableCleanupResult> {
111121
const result: TableCleanupResult = { table: tableName, deleted: 0, failed: 0 }
112122

@@ -149,7 +159,7 @@ export async function chunkedBatchDelete<TRow extends { id: string }>({
149159
if (onBatch) await onBatch(rows)
150160

151161
const ids = rows.map((r) => r.id)
152-
const deleted = await db
162+
const deleted = await dbClient
153163
.delete(tableDef)
154164
.where(inArray(sql`id`, ids))
155165
.returning({ id: sql`id` })
@@ -189,6 +199,8 @@ export interface BatchDeleteOptions {
189199
batchSize?: number
190200
maxBatches?: number
191201
workspaceChunkSize?: number
202+
/** Client the SELECTs and DELETEs run on. Defaults to the global pool. */
203+
dbClient?: BatchDeleteClient
192204
}
193205

194206
/**
@@ -204,16 +216,18 @@ export async function batchDeleteByWorkspaceAndTimestamp({
204216
retentionDate,
205217
tableName,
206218
requireTimestampNotNull = false,
219+
dbClient = db,
207220
...rest
208221
}: BatchDeleteOptions): Promise<TableCleanupResult> {
209222
return chunkedBatchDelete({
210223
tableDef,
211224
workspaceIds,
212225
tableName,
226+
dbClient,
213227
selectChunk: (chunkIds, limit) => {
214228
const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)]
215229
if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol))
216-
return db
230+
return dbClient
217231
.select({ id: sql<string>`id` })
218232
.from(tableDef)
219233
.where(and(...predicates))
@@ -232,6 +246,7 @@ export async function deleteRowsById(
232246
idCol: PgColumn,
233247
ids: string[],
234248
tableName: string,
249+
dbClient: BatchDeleteClient = db,
235250
chunkSize: number = DEFAULT_DELETE_CHUNK_SIZE
236251
): Promise<TableCleanupResult> {
237252
const result: TableCleanupResult = { table: tableName, deleted: 0, failed: 0 }
@@ -240,7 +255,7 @@ export async function deleteRowsById(
240255
const chunks = chunkArray(ids, chunkSize)
241256
for (const [chunkIdx, chunkIds] of chunks.entries()) {
242257
try {
243-
const deleted = await db
258+
const deleted = await dbClient
244259
.delete(tableDef)
245260
.where(inArray(idCol, chunkIds))
246261
.returning({ id: idCol })

0 commit comments

Comments
 (0)