|
| 1 | +import { |
| 2 | + dbChainMock, |
| 3 | + dbChainMockFns, |
| 4 | + hasMockCondition, |
| 5 | + queueTableRows, |
| 6 | + resetDbChainMock, |
| 7 | + schemaMock, |
| 8 | +} from '@sim/testing' |
| 9 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 10 | + |
| 11 | +const { storage, prepareChat, executeChat, hardDelete, billing, decrement, reRoot } = vi.hoisted( |
| 12 | + () => ({ |
| 13 | + storage: vi.fn(), |
| 14 | + prepareChat: vi.fn(), |
| 15 | + executeChat: vi.fn(), |
| 16 | + hardDelete: vi.fn(), |
| 17 | + billing: vi.fn(), |
| 18 | + decrement: vi.fn(), |
| 19 | + reRoot: vi.fn(), |
| 20 | + }) |
| 21 | +) |
| 22 | +vi.mock('@/background/cleanup-logs', () => ({ legacyLargeValuePredicate: vi.fn() })) |
| 23 | +vi.mock('@/background/cleanup-soft-deletes', () => ({ |
| 24 | + reRootActiveFolderChildrenUnguarded: reRoot, |
| 25 | +})) |
| 26 | +vi.mock('@/lib/uploads', () => ({ |
| 27 | + isUsingCloudStorage: () => true, |
| 28 | + StorageService: { deleteFiles: storage }, |
| 29 | +})) |
| 30 | +vi.mock('@/lib/cleanup/chat-cleanup', () => ({ prepareChatCleanup: prepareChat })) |
| 31 | +vi.mock('@/lib/knowledge/documents/service', () => ({ hardDeleteDocuments: hardDelete })) |
| 32 | +vi.mock('@/lib/billing/storage', () => ({ |
| 33 | + resolveStorageBillingContext: billing, |
| 34 | + decrementStorageUsageForBillingContextInTx: decrement, |
| 35 | +})) |
| 36 | + |
| 37 | +import { BoundedCleanup, type CleanupTransaction } from '@/lib/cleanup/bounded' |
| 38 | +import type { CleanupType } from '@/lib/cleanup/bounded-types' |
| 39 | +import { runBoundedLogScope } from '@/background/cleanup-logs-bounded' |
| 40 | +import { runBoundedSoftDeleteScope } from '@/background/cleanup-soft-deletes-bounded' |
| 41 | + |
| 42 | +const scope = { |
| 43 | + plan: 'free' as const, |
| 44 | + workspaceIds: ['ws-one'], |
| 45 | + retentionHours: 720, |
| 46 | + label: 'test', |
| 47 | + runGlobalHousekeeping: true, |
| 48 | +} |
| 49 | +function control(type: CleanupType, dryRun = true, limit = 1) { |
| 50 | + return new BoundedCleanup( |
| 51 | + { limits: { [type]: limit }, batchSize: 1, dryRun, requestId: 'test' }, |
| 52 | + async () => {}, |
| 53 | + Date.now, |
| 54 | + async (query) => query(dbChainMock.db as CleanupTransaction) |
| 55 | + ) |
| 56 | +} |
| 57 | +beforeEach(() => { |
| 58 | + vi.clearAllMocks() |
| 59 | + resetDbChainMock() |
| 60 | + storage.mockResolvedValue({ deleted: 1, failed: [] }) |
| 61 | + prepareChat.mockResolvedValue({ execute: executeChat }) |
| 62 | +}) |
| 63 | + |
| 64 | +describe('requested cleanup stages', () => { |
| 65 | + const targets = [ |
| 66 | + ['workflowLogs', schemaMock.workflowExecutionLogs], |
| 67 | + ['jobLogs', schemaMock.jobExecutionLogs], |
| 68 | + ['largeValues', schemaMock.executionLargeValues], |
| 69 | + ['legacyLargeValues', schemaMock.workspaceFiles], |
| 70 | + ['orphanSnapshots', schemaMock.workflowExecutionSnapshots], |
| 71 | + ['workflows', schemaMock.workflow], |
| 72 | + ['chats', schemaMock.copilotChats], |
| 73 | + ['legacyFiles', schemaMock.workspaceFile], |
| 74 | + ['files', schemaMock.workspaceFiles], |
| 75 | + ['knowledgeBases', schemaMock.knowledgeBase], |
| 76 | + ['folders', schemaMock.folder], |
| 77 | + ['userTables', schemaMock.userTableDefinitions], |
| 78 | + ['memories', schemaMock.memory], |
| 79 | + ['mcpServers', schemaMock.mcpServers], |
| 80 | + ['workflowMcpServers', schemaMock.workflowMcpServer], |
| 81 | + ['orphanKnowledgeBaseBindings', schemaMock.workspaceFiles], |
| 82 | + ] as const |
| 83 | + it.each(targets)( |
| 84 | + 'dry run of %s selects only that stage and has no side effects', |
| 85 | + async (type, table) => { |
| 86 | + queueTableRows(table, [{ id: 'root-one', key: 'key-one', files: [{ key: 'attached-file' }] }]) |
| 87 | + const run = control(type) |
| 88 | + const runner = |
| 89 | + targets.findIndex(([candidate]) => candidate === type) < 5 |
| 90 | + ? runBoundedLogScope |
| 91 | + : runBoundedSoftDeleteScope |
| 92 | + await runner(scope, run) |
| 93 | + expect(run.progress.stages[type]?.selected).toBe(1) |
| 94 | + expect(Object.keys(run.progress.stages)).toEqual([type]) |
| 95 | + expect(dbChainMockFns.delete).not.toHaveBeenCalled() |
| 96 | + expect(dbChainMockFns.update).not.toHaveBeenCalled() |
| 97 | + for (const effect of [storage, prepareChat, hardDelete, billing, decrement, reRoot]) |
| 98 | + expect(effect).not.toHaveBeenCalled() |
| 99 | + } |
| 100 | + ) |
| 101 | + it.each(['staleReferences', 'staleDependencies', 'largeValueTombstones'] as const)( |
| 102 | + 'dry run of %s uses SELECT only', |
| 103 | + async (type) => { |
| 104 | + dbChainMockFns.execute.mockResolvedValueOnce([{ id: 'metadata-one' }]) |
| 105 | + const run = control(type) |
| 106 | + await runBoundedLogScope(scope, run) |
| 107 | + expect(run.progress.stages[type]?.selected).toBe(1) |
| 108 | + expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1) |
| 109 | + expect(dbChainMockFns.execute.mock.calls[0][0].strings.join('')).toMatch(/^SELECT /) |
| 110 | + } |
| 111 | + ) |
| 112 | + it('keeps the same log budget across workspace chunks', async () => { |
| 113 | + const ids = Array.from({ length: 51 }, (_, index) => `ws-${index}`) |
| 114 | + queueTableRows(schemaMock.jobExecutionLogs, [{ id: 'one' }]) |
| 115 | + queueTableRows(schemaMock.jobExecutionLogs, []) |
| 116 | + queueTableRows(schemaMock.jobExecutionLogs, [{ id: 'two' }]) |
| 117 | + dbChainMockFns.returning.mockResolvedValue([{ id: 'deleted' }]) |
| 118 | + const run = control('jobLogs', false, 2) |
| 119 | + await runBoundedLogScope({ ...scope, workspaceIds: ids }, run) |
| 120 | + expect(run.progress.stages.jobLogs).toMatchObject({ selected: 2, deleted: 2 }) |
| 121 | + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2) |
| 122 | + }) |
| 123 | + it('stops before root deletion if attached log storage fails', async () => { |
| 124 | + queueTableRows(schemaMock.workflowExecutionLogs, [{ id: 'one', files: [{ key: 'blob' }] }]) |
| 125 | + storage.mockResolvedValue({ deleted: 0, failed: [{ key: 'blob', error: 'unavailable' }] }) |
| 126 | + const run = control('workflowLogs', false) |
| 127 | + await expect(runBoundedLogScope(scope, run)).rejects.toThrow('storage deletions failed') |
| 128 | + expect(dbChainMockFns.delete).not.toHaveBeenCalled() |
| 129 | + expect(run.progress.stages.workflowLogs).toMatchObject({ |
| 130 | + selected: 1, |
| 131 | + deleted: 0, |
| 132 | + filesFailed: 1, |
| 133 | + }) |
| 134 | + }) |
| 135 | + it('keeps workflow chat side effects even with a zero chat budget', async () => { |
| 136 | + queueTableRows(schemaMock.workflow, [{ id: 'workflow-one' }]) |
| 137 | + queueTableRows(schemaMock.copilotChats, [{ id: 'child-chat' }]) |
| 138 | + queueTableRows(schemaMock.copilotChats, []) |
| 139 | + dbChainMockFns.returning.mockResolvedValue([{ id: 'workflow-one' }]) |
| 140 | + const run = control('workflows', false) |
| 141 | + await runBoundedSoftDeleteScope(scope, run) |
| 142 | + expect(prepareChat).toHaveBeenCalledWith( |
| 143 | + ['child-chat'], |
| 144 | + 'test', |
| 145 | + expect.objectContaining({ type: 'workflows' }) |
| 146 | + ) |
| 147 | + expect(executeChat).toHaveBeenCalledTimes(1) |
| 148 | + expect(run.progress.stages.workflows?.deleted).toBe(1) |
| 149 | + expect(run.progress.stages.chats).toBeUndefined() |
| 150 | + }) |
| 151 | + it('counts committed workflow deletion when backend cleanup subsequently fails', async () => { |
| 152 | + queueTableRows(schemaMock.workflow, [{ id: 'workflow-one' }]) |
| 153 | + dbChainMockFns.returning.mockResolvedValue([{ id: 'workflow-one' }]) |
| 154 | + executeChat.mockRejectedValueOnce(new Error('backend failure')) |
| 155 | + const run = control('workflows', false) |
| 156 | + await expect(runBoundedSoftDeleteScope(scope, run)).rejects.toThrow('backend failure') |
| 157 | + expect(run.progress.stages.workflows?.deleted).toBe(1) |
| 158 | + }) |
| 159 | + it('applies the shared file budget across workspace and organization scopes', async () => { |
| 160 | + const run = control('files', true, 2) |
| 161 | + queueTableRows(schemaMock.workspaceFiles, [{ id: 'workspace-file' }]) |
| 162 | + queueTableRows(schemaMock.workspaceFiles, []) |
| 163 | + await runBoundedSoftDeleteScope(scope, run) |
| 164 | + queueTableRows(schemaMock.workspaceFiles, [{ id: 'organization-file' }]) |
| 165 | + await runBoundedSoftDeleteScope( |
| 166 | + { ...scope, workspaceIds: [], organizationIds: ['org-one'] }, |
| 167 | + run |
| 168 | + ) |
| 169 | + expect(run.progress.stages.files?.selected).toBe(2) |
| 170 | + expect(storage).not.toHaveBeenCalled() |
| 171 | + }) |
| 172 | +}) |
| 173 | + |
| 174 | +describe('bounded file billing', () => { |
| 175 | + it('rechecks the exact payer workspace and decrements only bytes actually deleted', async () => { |
| 176 | + queueTableRows(schemaMock.workspaceFiles, [ |
| 177 | + { id: 'file-one', key: 'blob', context: 'workspace', workspaceId: 'ws-one', sizeBytes: 100 }, |
| 178 | + ]) |
| 179 | + billing.mockResolvedValue({ workspaceId: 'ws-one' }) |
| 180 | + dbChainMockFns.returning.mockResolvedValue([{ id: 'file-one', sizeBytes: 40 }]) |
| 181 | + const run = control('files', false) |
| 182 | + await runBoundedSoftDeleteScope({ ...scope, workspaceIds: ['ws-one', 'ws-two'] }, run) |
| 183 | + expect(decrement).toHaveBeenCalledWith( |
| 184 | + expect.anything(), |
| 185 | + expect.objectContaining({ workspaceId: 'ws-one' }), |
| 186 | + 40 |
| 187 | + ) |
| 188 | + expect( |
| 189 | + dbChainMockFns.where.mock.calls.some(([predicate]) => |
| 190 | + hasMockCondition( |
| 191 | + predicate, |
| 192 | + (condition) => |
| 193 | + condition.type === 'eq' && |
| 194 | + condition.left === schemaMock.workspaceFiles.workspaceId && |
| 195 | + condition.right === 'ws-one' |
| 196 | + ) |
| 197 | + ) |
| 198 | + ).toBe(true) |
| 199 | + expect(run.progress.stages.files?.deleted).toBe(1) |
| 200 | + }) |
| 201 | + it('validates canonical sizes before deleting any storage', async () => { |
| 202 | + queueTableRows(schemaMock.workspaceFiles, [ |
| 203 | + { id: 'file-one', key: 'blob', context: 'workspace', workspaceId: 'ws-one', sizeBytes: null }, |
| 204 | + ]) |
| 205 | + await expect(runBoundedSoftDeleteScope(scope, control('files', false))).rejects.toThrow( |
| 206 | + 'canonical size_bytes' |
| 207 | + ) |
| 208 | + expect(storage).not.toHaveBeenCalled() |
| 209 | + expect(dbChainMockFns.delete).not.toHaveBeenCalled() |
| 210 | + }) |
| 211 | +}) |
0 commit comments