(payload: CleanupJobPayload)
| 250 | } |
| 251 | |
| 252 | export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise<void> { |
| 253 | const startTime = Date.now() |
| 254 | const { workspaceIds, retentionHours, label } = payload |
| 255 | |
| 256 | if (workspaceIds.length === 0) { |
| 257 | logger.info(`[${label}] No workspaces to process`) |
| 258 | return |
| 259 | } |
| 260 | |
| 261 | const retentionDate = new Date(Date.now() - retentionHours * 60 * 60 * 1000) |
| 262 | logger.info( |
| 263 | `[${label}] Processing ${workspaceIds.length} workspaces, cutoff: ${retentionDate.toISOString()}` |
| 264 | ) |
| 265 | |
| 266 | // Select workflows + files once. These sets drive BOTH external cleanup |
| 267 | // (chats + S3) AND the DB deletes below — selecting twice could return |
| 268 | // different subsets above the LIMIT cap and orphan or prematurely purge data. |
| 269 | const [doomedWorkflows, fileScope] = await Promise.all([ |
| 270 | selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => |
| 271 | db |
| 272 | .select({ id: workflow.id }) |
| 273 | .from(workflow) |
| 274 | .where( |
| 275 | and( |
| 276 | inArray(workflow.workspaceId, chunkIds), |
| 277 | isNotNull(workflow.archivedAt), |
| 278 | lt(workflow.archivedAt, retentionDate) |
| 279 | ) |
| 280 | ) |
| 281 | .limit(chunkLimit) |
| 282 | ), |
| 283 | selectExpiredWorkspaceFiles(workspaceIds, retentionDate), |
| 284 | ]) |
| 285 | |
| 286 | const doomedWorkflowIds = doomedWorkflows.map((w) => w.id) |
| 287 | let chatCleanup: { execute: () => Promise<void> } | null = null |
| 288 | |
| 289 | if (doomedWorkflowIds.length > 0) { |
| 290 | const doomedChats = await selectRowsByIdChunks(doomedWorkflowIds, (chunkIds, chunkLimit) => |
| 291 | db |
| 292 | .select({ id: copilotChats.id }) |
| 293 | .from(copilotChats) |
| 294 | .where(inArray(copilotChats.workflowId, chunkIds)) |
| 295 | .limit(chunkLimit) |
| 296 | ) |
| 297 | |
| 298 | const doomedChatIds = doomedChats.map((c) => c.id) |
| 299 | if (doomedChatIds.length > 0) { |
| 300 | chatCleanup = await prepareChatCleanup(doomedChatIds, label) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | const fileStats = await cleanupWorkspaceFileStorage(fileScope) |
| 305 | |
| 306 | let totalDeleted = 0 |
| 307 | |
| 308 | // Delete the workflow + file rows using the exact IDs we already selected. |
| 309 | const workflowResult = await deleteRowsById( |
no test coverage detected