fix(large-refs): cleanup based on table read (#4716)

* fix(large-refs): cleanup based on table read

* address comments

* address comments

* bubble up storage ref errors

* cleanup code

* do not attempt blob deletion for infra outage

* cleanup dup helper
This commit is contained in:
Vikhyath Mondreti
2026-05-22 12:08:22 -07:00
committed by GitHub
parent 786c6f0607
commit 209ca5f121
18 changed files with 19392 additions and 135 deletions
@@ -15,8 +15,16 @@ import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
import type { ExecutionResult } from '@/lib/workflows/types'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
const { mockDownloadFile, mockUploadFile, uploadedFiles } = vi.hoisted(() => ({
const {
mockAddLargeValueReference,
mockDownloadFile,
mockRegisterLargeValueOwner,
mockUploadFile,
uploadedFiles,
} = vi.hoisted(() => ({
mockAddLargeValueReference: vi.fn(),
mockDownloadFile: vi.fn(),
mockRegisterLargeValueOwner: vi.fn(),
mockUploadFile: vi.fn(),
uploadedFiles: new Map<string, Buffer>(),
}))
@@ -35,6 +43,11 @@ vi.mock('@/lib/uploads', () => ({
},
}))
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
addLargeValueReference: mockAddLargeValueReference,
registerLargeValueOwner: mockRegisterLargeValueOwner,
}))
function buildExecutionResult(overrides: Partial<ExecutionResult> = {}): ExecutionResult {
return {
success: true,
@@ -66,6 +79,8 @@ describe('Response block gating by auth type', () => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
uploadedFiles.clear()
mockAddLargeValueReference.mockResolvedValue(undefined)
mockRegisterLargeValueOwner.mockResolvedValue(true)
mockUploadFile.mockImplementation(async ({ customKey, file }) => {
uploadedFiles.set(customKey, file)
return { key: customKey }
+311
View File
@@ -0,0 +1,311 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface CleanupRow {
id: string
files: unknown
}
interface CapturedBatchDeleteOptions {
selectChunk: (chunkIds: string[], limit: number) => Promise<unknown>
onBatch?: (rows: CleanupRow[]) => Promise<void>
batchSize?: number
maxBatches?: number
totalRowLimit?: number
}
const {
mockAnd,
mockBatchDeleteByWorkspaceAndTimestamp,
mockChunkedBatchDelete,
mockDeleteFileMetadata,
mockDeleteFiles,
mockEq,
mockExecute,
mockFrom,
mockInArray,
mockIsNull,
mockLeftJoin,
mockLimit,
mockLt,
mockMarkLargeValuesDeleted,
mockNotInArray,
mockOr,
mockOrderBy,
mockPruneLargeValueMetadata,
mockSelect,
mockTask,
mockWhere,
} = vi.hoisted(() => {
const mockLimit = vi.fn(async () => [])
const mockOrderBy = vi.fn(() => ({ limit: mockLimit }))
const mockWhere = vi.fn(() => ({ limit: mockLimit, orderBy: mockOrderBy }))
const mockLeftJoin = vi.fn(() => ({ where: mockWhere }))
const mockFrom = vi.fn(() => ({ leftJoin: mockLeftJoin, where: mockWhere }))
const mockSelect = vi.fn(() => ({ from: mockFrom }))
return {
mockAnd: vi.fn((...args: unknown[]) => ({ op: 'and', args })),
mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({
table: 'job',
deleted: 0,
failed: 0,
})),
mockChunkedBatchDelete: vi.fn(),
mockDeleteFileMetadata: vi.fn(async () => true),
mockDeleteFiles: vi.fn(async () => ({ deleted: 2, failed: [] })),
mockEq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })),
mockExecute: vi.fn(),
mockFrom,
mockInArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })),
mockIsNull: vi.fn((...args: unknown[]) => ({ op: 'isNull', args })),
mockLeftJoin,
mockLimit,
mockLt: vi.fn((...args: unknown[]) => ({ op: 'lt', args })),
mockMarkLargeValuesDeleted: vi.fn(async () => undefined),
mockNotInArray: vi.fn((...args: unknown[]) => ({ op: 'notInArray', args })),
mockOr: vi.fn((...args: unknown[]) => ({ op: 'or', args })),
mockOrderBy,
mockPruneLargeValueMetadata: vi.fn(async () => ({
referencesDeleted: 0,
dependenciesDeleted: 0,
tombstonesDeleted: 0,
})),
mockSelect,
mockTask: vi.fn((config: unknown) => config),
mockWhere,
}
})
vi.mock('@sim/db', () => ({
db: {
execute: mockExecute,
select: mockSelect,
},
}))
vi.mock('@sim/db/schema', () => ({
executionLargeValueDependencies: {
childKey: 'executionLargeValueDependencies.childKey',
parentKey: 'executionLargeValueDependencies.parentKey',
workspaceId: 'executionLargeValueDependencies.workspaceId',
},
executionLargeValueReferences: {
executionId: 'executionLargeValueReferences.executionId',
key: 'executionLargeValueReferences.key',
source: 'executionLargeValueReferences.source',
},
executionLargeValues: {
createdAt: 'executionLargeValues.createdAt',
deletedAt: 'executionLargeValues.deletedAt',
key: 'executionLargeValues.key',
workspaceId: 'executionLargeValues.workspaceId',
},
jobExecutionLogs: {
startedAt: 'jobExecutionLogs.startedAt',
workspaceId: 'jobExecutionLogs.workspaceId',
},
pausedExecutions: {
executionId: 'pausedExecutions.executionId',
status: 'pausedExecutions.status',
},
workspaceFiles: {
context: 'workspaceFiles.context',
deletedAt: 'workspaceFiles.deletedAt',
key: 'workspaceFiles.key',
uploadedAt: 'workspaceFiles.uploadedAt',
workspaceId: 'workspaceFiles.workspaceId',
},
workflowExecutionLogs: {
executionData: 'workflowExecutionLogs.executionData',
executionId: 'workflowExecutionLogs.executionId',
files: 'workflowExecutionLogs.files',
id: 'workflowExecutionLogs.id',
startedAt: 'workflowExecutionLogs.startedAt',
workspaceId: 'workflowExecutionLogs.workspaceId',
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
})),
}))
vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
vi.mock('drizzle-orm', () => ({
and: mockAnd,
asc: vi.fn((column: unknown) => ({ op: 'asc', column })),
eq: mockEq,
inArray: mockInArray,
isNull: mockIsNull,
lt: mockLt,
notInArray: mockNotInArray,
or: mockOr,
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
}))
vi.mock('@/lib/cleanup/batch-delete', () => ({
batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp,
chunkArray: (items: string[], size: number) => {
const chunks: string[][] = []
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size))
}
return chunks
},
chunkedBatchDelete: mockChunkedBatchDelete,
}))
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
LIVE_PAUSED_REFERENCE_STATUSES: ['paused', 'partially_resumed', 'cancelling'],
markLargeValuesDeleted: mockMarkLargeValuesDeleted,
pruneLargeValueMetadata: mockPruneLargeValueMetadata,
unreferencedLargeValuePredicate: vi.fn(() => ({ op: 'unreferencedLargeValuePredicate' })),
}))
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
snapshotService: {
cleanupOrphanedSnapshots: vi.fn(async () => 0),
},
}))
vi.mock('@/lib/uploads', () => ({
isUsingCloudStorage: vi.fn(() => true),
StorageService: {
deleteFiles: mockDeleteFiles,
},
}))
vi.mock('@/lib/uploads/server/metadata', () => ({
deleteFileMetadata: mockDeleteFileMetadata,
}))
import { cleanupLogsTask, runCleanupLogs } from '@/background/cleanup-logs'
describe('cleanup logs worker', () => {
beforeEach(() => {
vi.clearAllMocks()
mockChunkedBatchDelete.mockImplementation(async (options: CapturedBatchDeleteOptions) => {
await options.selectChunk(['workspace-1'], 500)
await options.onBatch?.([
{
id: 'log-1',
files: [
{ key: 'execution-file-a' },
{ key: 'execution-file-a' },
{ key: 'execution-file-b' },
],
},
])
return { table: 'workflow_execution_logs', deleted: 1, failed: 0 }
})
})
it('cleans logs without selecting execution_data or scanning refs', async () => {
await runCleanupLogs({
label: 'free/1',
plan: 'free',
retentionHours: 720,
workspaceIds: ['workspace-1'],
})
expect(mockChunkedBatchDelete).toHaveBeenCalledWith(
expect.objectContaining({
batchSize: 500,
maxBatches: 50,
totalRowLimit: 25_000,
})
)
expect(mockSelect).toHaveBeenCalledWith({
id: 'workflowExecutionLogs.id',
files: 'workflowExecutionLogs.files',
})
expect(mockExecute).not.toHaveBeenCalled()
expect(mockDeleteFiles).toHaveBeenCalledWith(
['execution-file-a', 'execution-file-b'],
'execution'
)
expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(2)
expect(mockPruneLargeValueMetadata).toHaveBeenCalledWith(
expect.objectContaining({ workspaceIds: ['workspace-1'] })
)
expect(mockBatchDeleteByWorkspaceAndTimestamp).toHaveBeenCalledOnce()
})
it('does not count large values as deleted when deleted_at marking fails', async () => {
const largeValueKey =
'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json'
mockLimit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ key: largeValueKey }])
mockDeleteFiles
.mockResolvedValueOnce({ deleted: 2, failed: [] })
.mockResolvedValueOnce({ deleted: 1, failed: [] })
mockMarkLargeValuesDeleted.mockRejectedValueOnce(new Error('db unavailable'))
await runCleanupLogs({
label: 'free/1',
plan: 'free',
retentionHours: 720,
workspaceIds: ['workspace-1'],
})
expect(mockMarkLargeValuesDeleted).toHaveBeenCalledWith([largeValueKey])
expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(2)
})
it('cleans legacy large values from file metadata without selecting execution_data', async () => {
const legacyKey =
'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json'
mockLimit
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ key: legacyKey }])
mockDeleteFiles
.mockResolvedValueOnce({ deleted: 2, failed: [] })
.mockResolvedValueOnce({ deleted: 1, failed: [] })
await runCleanupLogs({
label: 'free/1',
plan: 'free',
retentionHours: 720,
workspaceIds: ['workspace-1'],
})
expect(mockSelect).toHaveBeenCalledWith({
id: 'workflowExecutionLogs.id',
files: 'workflowExecutionLogs.files',
})
expect(mockSelect).not.toHaveBeenCalledWith(
expect.objectContaining({ executionData: expect.anything() })
)
const legacyWhereArgs = mockAnd.mock.calls
.flat()
.filter((arg): arg is { strings: string[] } => {
return (
typeof arg === 'object' &&
arg !== null &&
Array.isArray((arg as { strings?: unknown }).strings)
)
})
.map((arg) => arg.strings.join(' '))
.join(' ')
expect(legacyWhereArgs).toContain('FROM ')
expect(legacyWhereArgs).toContain("ref.source = 'execution_log'")
expect(legacyWhereArgs).toContain("ref.source = 'paused_snapshot'")
expect(legacyWhereArgs).toContain('dependency.child_key')
expect(mockDeleteFiles).toHaveBeenLastCalledWith([legacyKey], 'execution')
expect(mockDeleteFileMetadata).toHaveBeenCalledWith(legacyKey)
})
it('caps Trigger.dev concurrency for log cleanup tasks', () => {
expect(cleanupLogsTask).toMatchObject({
queue: { concurrencyLimit: 2 },
})
})
})
+343 -89
View File
@@ -1,8 +1,16 @@
import { db } from '@sim/db'
import { jobExecutionLogs, pausedExecutions, workflowExecutionLogs } from '@sim/db/schema'
import {
executionLargeValueDependencies,
executionLargeValueReferences,
executionLargeValues,
jobExecutionLogs,
pausedExecutions,
workflowExecutionLogs,
workspaceFiles,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { and, eq, inArray, isNull, lt, notInArray, or, sql } from 'drizzle-orm'
import { and, asc, eq, inArray, isNull, lt, notInArray, or, sql } from 'drizzle-orm'
import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher'
import {
batchDeleteByWorkspaceAndTimestamp,
@@ -10,7 +18,12 @@ import {
chunkedBatchDelete,
type TableCleanupResult,
} from '@/lib/cleanup/batch-delete'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
import {
LIVE_PAUSED_REFERENCE_STATUSES,
markLargeValuesDeleted,
pruneLargeValueMetadata,
unreferencedLargeValuePredicate,
} from '@/lib/execution/payloads/large-value-metadata'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
import { deleteFileMetadata } from '@/lib/uploads/server/metadata'
@@ -21,90 +34,332 @@ interface FileDeleteStats {
filesTotal: number
filesDeleted: number
filesDeleteFailed: number
}
const WORKFLOW_LOG_CLEANUP_BATCH_SIZE = 500
const WORKFLOW_LOG_CLEANUP_MAX_BATCHES = 50
const WORKFLOW_LOG_CLEANUP_ROW_LIMIT =
WORKFLOW_LOG_CLEANUP_BATCH_SIZE * WORKFLOW_LOG_CLEANUP_MAX_BATCHES
const LOG_CLEANUP_CONCURRENCY_LIMIT = 2
const LARGE_VALUE_CLEANUP_BATCH_SIZE = 500
const LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT = 5_000
const LARGE_VALUE_CLEANUP_GRACE_HOURS = 7 * 24
const LEGACY_LARGE_VALUE_CLEANUP_GRACE_HOURS = 30 * 24
const LARGE_VALUE_TOMBSTONE_RETENTION_HOURS = 30 * 24
async function deleteExecutionFiles(files: unknown, stats: FileDeleteStats): Promise<void> {
if (!isUsingCloudStorage() || !files || !Array.isArray(files)) return
const keys = Array.from(
new Set(files.filter((f) => f && typeof f === 'object' && f.key).map((f) => f.key as string))
)
stats.filesTotal += keys.length
if (keys.length === 0) return
let result: Awaited<ReturnType<typeof StorageService.deleteFiles>>
try {
result = await StorageService.deleteFiles(keys, 'execution')
} catch (error) {
stats.filesDeleteFailed += keys.length
logger.error('Failed to bulk delete execution files:', { error })
return
}
const failedKeys = new Set(result.failed.map(({ key }) => key))
stats.filesDeleted += result.deleted
stats.filesDeleteFailed += result.failed.length
for (const { key, error } of result.failed) {
logger.error(`Failed to delete file ${key}:`, { error })
}
for (const key of keys) {
if (failedKeys.has(key)) continue
try {
await deleteFileMetadata(key)
} catch (metadataError) {
stats.filesDeleteFailed++
logger.error(`Failed to delete file metadata ${key}:`, { metadataError })
}
}
}
interface LargeValueCleanupStats {
largeValuesTotal: number
largeValuesDeleted: number
largeValuesDeleteFailed: number
}
const RESUMABLE_PAUSED_STATUSES = ['paused', 'partially_resumed', 'cancelling']
/** Caps the per-row predicate cost: keys-per-row is `O(chunk)` not `O(uniqueKeys)`. */
const REFERENCE_CHECK_KEY_CHUNK_SIZE = 200
/**
* One `LATERAL unnest` scan per chunk replaces N per-key sequential scans
* (each detoasting the entire JSONB column). Substring semantics identical.
*/
async function filterLargeValueKeysWithoutRetainedReferences(
keys: string[],
deletedLogIds: string[]
): Promise<string[]> {
if (keys.length === 0 || deletedLogIds.length === 0) return []
const uniqueKeys = Array.from(new Set(keys))
const workspaceIds = Array.from(
new Set(
uniqueKeys
.map((key) => key.split('/')[1])
.filter((workspaceId): workspaceId is string => Boolean(workspaceId))
)
)
if (workspaceIds.length === 0) return []
const referencedKeys = new Set<string>()
for (const keyChunk of chunkArray(uniqueKeys, REFERENCE_CHECK_KEY_CHUNK_SIZE)) {
const rows = await db.execute<{ key: string }>(sql`
SELECT DISTINCT k.key AS key
FROM ${workflowExecutionLogs} AS wel,
unnest(${keyChunk}::text[]) AS k(key)
WHERE wel.workspace_id = ANY(${workspaceIds}::text[])
AND wel.id <> ALL(${deletedLogIds}::text[])
AND position(k.key in wel.execution_data::text) > 0
`)
for (const row of rows) referencedKeys.add(row.key)
async function deleteLargeValueKeys(keys: string[]): Promise<{ deleted: number; failed: number }> {
if (!isUsingCloudStorage() || keys.length === 0) {
return { deleted: 0, failed: 0 }
}
return uniqueKeys.filter((key) => !referencedKeys.has(key))
let result: Awaited<ReturnType<typeof StorageService.deleteFiles>>
try {
result = await StorageService.deleteFiles(keys, 'execution')
} catch (error) {
logger.error('Failed to bulk delete large execution values:', { error })
return { deleted: 0, failed: keys.length }
}
const failedKeys = new Set(result.failed.map(({ key }) => key))
const deletedKeys = keys.filter((key) => !failedKeys.has(key))
if (deletedKeys.length > 0) {
try {
await markLargeValuesDeleted(deletedKeys)
} catch (error) {
logger.error('Failed to mark large execution values as deleted:', { error })
return { deleted: 0, failed: result.failed.length + deletedKeys.length }
}
}
for (const { key, error } of result.failed) {
logger.error(`Failed to delete large execution value ${key}:`, { error })
}
for (const key of deletedKeys) {
try {
await deleteFileMetadata(key)
} catch (metadataError) {
logger.error(`Failed to delete large execution value metadata ${key}:`, { metadataError })
}
}
return { deleted: deletedKeys.length, failed: result.failed.length }
}
async function deleteExecutionFiles(files: unknown, stats: FileDeleteStats): Promise<void> {
if (!isUsingCloudStorage() || !files || !Array.isArray(files)) return
async function cleanupLargeExecutionValues(
workspaceIds: string[],
retentionDate: Date,
label: string
): Promise<LargeValueCleanupStats> {
const stats: LargeValueCleanupStats = {
largeValuesTotal: 0,
largeValuesDeleted: 0,
largeValuesDeleteFailed: 0,
}
if (workspaceIds.length === 0) return stats
const keys = files.filter((f) => f && typeof f === 'object' && f.key).map((f) => f.key as string)
stats.filesTotal += keys.length
await Promise.all(
keys.map(async (key) => {
try {
await StorageService.deleteFile({ key, context: 'execution' })
await deleteFileMetadata(key)
stats.filesDeleted++
} catch (fileError) {
stats.filesDeleteFailed++
logger.error(`Failed to delete file ${key}:`, { fileError })
}
})
const largeValueRetentionDate = new Date(
retentionDate.getTime() - LARGE_VALUE_CLEANUP_GRACE_HOURS * 60 * 60 * 1000
)
const workspaceChunks = chunkArray(workspaceIds, 50)
let attempted = 0
for (const chunkIds of workspaceChunks) {
while (attempted < LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT) {
const limit = Math.min(
LARGE_VALUE_CLEANUP_BATCH_SIZE,
LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted
)
const rows = await db
.select({ key: executionLargeValues.key })
.from(executionLargeValues)
.where(
and(
inArray(executionLargeValues.workspaceId, chunkIds),
isNull(executionLargeValues.deletedAt),
lt(executionLargeValues.createdAt, largeValueRetentionDate),
unreferencedLargeValuePredicate()
)
)
.orderBy(
asc(executionLargeValues.workspaceId),
asc(executionLargeValues.createdAt),
asc(executionLargeValues.key)
)
.limit(limit)
if (rows.length === 0) break
const keys = rows.map((row) => row.key)
stats.largeValuesTotal += keys.length
attempted += keys.length
const result = await deleteLargeValueKeys(keys)
stats.largeValuesDeleted += result.deleted
stats.largeValuesDeleteFailed += result.failed
if (result.deleted === 0) {
break
}
}
if (attempted >= LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT) break
}
logger.info(
`[${label}/execution_large_values] Complete: ${stats.largeValuesDeleted}/${stats.largeValuesTotal} deleted, ${stats.largeValuesDeleteFailed} failed`
)
return stats
}
async function deleteLargeValueStorageKeys(keys: string[], stats: FileDeleteStats): Promise<void> {
if (!isUsingCloudStorage() || keys.length === 0) return
async function cleanupLegacyLargeExecutionValues(
workspaceIds: string[],
retentionDate: Date,
label: string
): Promise<LargeValueCleanupStats> {
const stats: LargeValueCleanupStats = {
largeValuesTotal: 0,
largeValuesDeleted: 0,
largeValuesDeleteFailed: 0,
}
if (workspaceIds.length === 0) return stats
const uniqueKeys = Array.from(new Set(keys))
stats.largeValuesTotal += uniqueKeys.length
await Promise.all(
uniqueKeys.map(async (key) => {
try {
await StorageService.deleteFile({ key, context: 'execution' })
await deleteFileMetadata(key)
stats.largeValuesDeleted++
} catch (error) {
stats.largeValuesDeleteFailed++
logger.error(`Failed to delete large execution value ${key}:`, { error })
}
})
const legacyRetentionDate = new Date(
retentionDate.getTime() - LEGACY_LARGE_VALUE_CLEANUP_GRACE_HOURS * 60 * 60 * 1000
)
const workspaceChunks = chunkArray(workspaceIds, 50)
let attempted = 0
for (const chunkIds of workspaceChunks) {
while (attempted < LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT) {
const limit = Math.min(
LARGE_VALUE_CLEANUP_BATCH_SIZE,
LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted
)
const rows = await db
.select({ key: workspaceFiles.key })
.from(workspaceFiles)
.where(
and(
inArray(workspaceFiles.workspaceId, chunkIds),
eq(workspaceFiles.context, 'execution'),
isNull(workspaceFiles.deletedAt),
lt(workspaceFiles.uploadedAt, legacyRetentionDate),
sql`${workspaceFiles.key} LIKE 'execution/%/%/%/large-value-lv_%.json'`,
sql`NOT EXISTS (
SELECT 1
FROM ${executionLargeValues} AS registered_value
WHERE registered_value.key = ${workspaceFiles.key}
)`,
sql`NOT EXISTS (
SELECT 1
FROM ${executionLargeValueReferences} AS ref
WHERE ref.key = ${workspaceFiles.key}
AND (
(
ref.source = 'execution_log'
AND EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS ref_wel
WHERE ref_wel.execution_id = ref.execution_id
)
)
OR (
ref.source = 'paused_snapshot'
AND EXISTS (
SELECT 1
FROM ${pausedExecutions} AS ref_pe
WHERE ref_pe.execution_id = ref.execution_id
AND ref_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
)
)`,
sql`NOT EXISTS (
SELECT 1
FROM ${executionLargeValueDependencies} AS dependency
INNER JOIN ${executionLargeValues} AS parent_value
ON parent_value.key = dependency.parent_key
AND parent_value.deleted_at IS NULL
WHERE dependency.child_key = ${workspaceFiles.key}
AND dependency.workspace_id = ${workspaceFiles.workspaceId}
AND (
EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS parent_owner_wel
WHERE parent_owner_wel.execution_id = parent_value.owner_execution_id
)
OR EXISTS (
SELECT 1
FROM ${pausedExecutions} AS parent_owner_pe
WHERE parent_owner_pe.execution_id = parent_value.owner_execution_id
AND parent_owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
OR EXISTS (
SELECT 1
FROM ${executionLargeValueReferences} AS parent_ref
WHERE parent_ref.key = parent_value.key
AND (
(
parent_ref.source = 'execution_log'
AND EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS parent_ref_wel
WHERE parent_ref_wel.execution_id = parent_ref.execution_id
)
)
OR (
parent_ref.source = 'paused_snapshot'
AND EXISTS (
SELECT 1
FROM ${pausedExecutions} AS parent_ref_pe
WHERE parent_ref_pe.execution_id = parent_ref.execution_id
AND parent_ref_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
)
)
)
)`,
sql`NOT EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS owner_wel
WHERE owner_wel.execution_id = split_part(${workspaceFiles.key}, '/', 4)
)`,
sql`NOT EXISTS (
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = split_part(${workspaceFiles.key}, '/', 4)
AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)`
)
)
.orderBy(
asc(workspaceFiles.workspaceId),
asc(workspaceFiles.uploadedAt),
asc(workspaceFiles.key)
)
.limit(limit)
if (rows.length === 0) break
const keys = rows.map((row) => row.key)
stats.largeValuesTotal += keys.length
attempted += keys.length
const result = await deleteLargeValueKeys(keys)
stats.largeValuesDeleted += result.deleted
stats.largeValuesDeleteFailed += result.failed
if (result.deleted === 0) {
break
}
}
if (attempted >= LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT) break
}
logger.info(
`[${label}/legacy_execution_large_values] Complete: ${stats.largeValuesDeleted}/${stats.largeValuesTotal} deleted, ${stats.largeValuesDeleteFailed} failed`
)
return stats
}
async function cleanupLargeValueMetadata(workspaceIds: string[], label: string): Promise<void> {
try {
const tombstonesDeletedBefore = new Date(
Date.now() - LARGE_VALUE_TOMBSTONE_RETENTION_HOURS * 60 * 60 * 1000
)
const result = await pruneLargeValueMetadata({ workspaceIds, tombstonesDeletedBefore })
logger.info(
`[${label}/execution_large_value_metadata] Pruned ${result.referencesDeleted} stale references, ${result.dependenciesDeleted} dependencies, ${result.tombstonesDeleted} tombstones`
)
} catch (error) {
logger.error(`[${label}/execution_large_value_metadata] Failed to prune metadata`, { error })
}
}
async function cleanupWorkflowExecutionLogs(
@@ -116,9 +371,6 @@ async function cleanupWorkflowExecutionLogs(
filesTotal: 0,
filesDeleted: 0,
filesDeleteFailed: 0,
largeValuesTotal: 0,
largeValuesDeleted: 0,
largeValuesDeleteFailed: 0,
}
const dbStats = await chunkedBatchDelete({
@@ -129,9 +381,6 @@ async function cleanupWorkflowExecutionLogs(
db
.select({
id: workflowExecutionLogs.id,
workspaceId: workflowExecutionLogs.workspaceId,
executionId: workflowExecutionLogs.executionId,
executionData: workflowExecutionLogs.executionData,
files: workflowExecutionLogs.files,
})
.from(workflowExecutionLogs)
@@ -145,24 +394,19 @@ async function cleanupWorkflowExecutionLogs(
lt(workflowExecutionLogs.startedAt, retentionDate),
or(
isNull(pausedExecutions.status),
notInArray(pausedExecutions.status, RESUMABLE_PAUSED_STATUSES)
notInArray(pausedExecutions.status, [...LIVE_PAUSED_REFERENCE_STATUSES])
)
)
)
.limit(limit),
onBatch: async (rows) => {
const deletedLogIds = rows.map((row) => row.id)
const largeValueKeys = rows.flatMap((row) => collectLargeValueKeys(row.executionData))
const unreferencedLargeValueKeys = await filterLargeValueKeysWithoutRetainedReferences(
largeValueKeys,
deletedLogIds
)
for (const row of rows) {
await deleteExecutionFiles(row.files, fileStats)
}
await deleteLargeValueStorageKeys(unreferencedLargeValueKeys, fileStats)
},
batchSize: WORKFLOW_LOG_CLEANUP_BATCH_SIZE,
maxBatches: WORKFLOW_LOG_CLEANUP_MAX_BATCHES,
totalRowLimit: WORKFLOW_LOG_CLEANUP_ROW_LIMIT,
})
return { ...dbStats, ...fileStats }
@@ -200,9 +444,19 @@ export async function runCleanupLogs(payload: CleanupJobPayload): Promise<void>
logger.info(
`[${label}] workflow_execution_logs files: ${workflowResults.filesDeleted}/${workflowResults.filesTotal} deleted, ${workflowResults.filesDeleteFailed} failed`
)
const largeValueResults = await cleanupLargeExecutionValues(workspaceIds, retentionDate, label)
logger.info(
`[${label}] workflow_execution_logs large values: ${workflowResults.largeValuesDeleted}/${workflowResults.largeValuesTotal} deleted, ${workflowResults.largeValuesDeleteFailed} failed`
`[${label}] execution_large_values: ${largeValueResults.largeValuesDeleted}/${largeValueResults.largeValuesTotal} deleted, ${largeValueResults.largeValuesDeleteFailed} failed`
)
const legacyLargeValueResults = await cleanupLegacyLargeExecutionValues(
workspaceIds,
retentionDate,
label
)
logger.info(
`[${label}] legacy_execution_large_values: ${legacyLargeValueResults.largeValuesDeleted}/${legacyLargeValueResults.largeValuesTotal} deleted, ${legacyLargeValueResults.largeValuesDeleteFailed} failed`
)
await cleanupLargeValueMetadata(workspaceIds, label)
await batchDeleteByWorkspaceAndTimestamp({
tableDef: jobExecutionLogs,
@@ -224,6 +478,6 @@ export async function runCleanupLogs(payload: CleanupJobPayload): Promise<void>
export const cleanupLogsTask = task({
id: 'cleanup-logs',
machine: 'large-1x',
queue: { concurrencyLimit: 5 },
queue: { concurrencyLimit: LOG_CLEANUP_CONCURRENCY_LIMIT },
run: runCleanupLogs,
})
+14 -8
View File
@@ -117,9 +117,10 @@ export async function chunkedBatchDelete<TRow extends { id: string }>({
const chunks = chunkArray(workspaceIds, workspaceChunkSize)
let stoppedEarly = false
let attempted = 0
for (const [chunkIdx, chunkIds] of chunks.entries()) {
if (result.deleted + result.failed >= totalRowLimit) {
if (attempted >= totalRowLimit) {
stoppedEarly = true
break
}
@@ -127,20 +128,24 @@ export async function chunkedBatchDelete<TRow extends { id: string }>({
let batchesProcessed = 0
let hasMore = true
while (
hasMore &&
batchesProcessed < maxBatches &&
result.deleted + result.failed < totalRowLimit
) {
while (hasMore && batchesProcessed < maxBatches && attempted < totalRowLimit) {
let rows: TRow[] = []
try {
rows = await selectChunk(chunkIds, batchSize)
const remainingLimit = totalRowLimit - attempted
const effectiveBatchSize = Math.min(batchSize, remainingLimit)
if (effectiveBatchSize <= 0) {
hasMore = false
break
}
rows = await selectChunk(chunkIds, effectiveBatchSize)
if (rows.length === 0) {
hasMore = false
break
}
attempted += rows.length
if (onBatch) await onBatch(rows)
const ids = rows.map((r) => r.id)
@@ -150,7 +155,8 @@ export async function chunkedBatchDelete<TRow extends { id: string }>({
.returning({ id: sql`id` })
result.deleted += deleted.length
hasMore = rows.length === batchSize
result.failed += rows.length - deleted.length
hasMore = rows.length === effectiveBatchSize && attempted < totalRowLimit
batchesProcessed++
} catch (error) {
// Count rows we tried to delete; SELECT-stage errors leave rows=[].
@@ -0,0 +1,457 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockAnd,
mockDelete,
mockEq,
mockExecute,
mockInsert,
mockOnConflictDoNothing,
mockSelect,
mockSelectFrom,
mockSelectLimit,
mockSelectWhere,
mockTransaction,
mockTxDelete,
mockTxInsert,
mockTxSelect,
mockTxSelectDistinct,
mockTxSelectFrom,
mockTxSelectLimit,
mockTxSelectWhere,
mockTxValues,
mockValues,
mockWhere,
mockTxWhere,
mockNotInArray,
} = vi.hoisted(() => {
const mockOnConflictDoNothing = vi.fn(async () => undefined)
const mockValues = vi.fn(() => ({ onConflictDoNothing: mockOnConflictDoNothing }))
const mockInsert = vi.fn(() => ({ values: mockValues }))
const mockWhere = vi.fn(async () => undefined)
const mockDelete = vi.fn(() => ({ where: mockWhere }))
const mockSelectLimit = vi.fn(async () => [])
const mockSelectWhere = vi.fn(() => ({ limit: mockSelectLimit }))
const mockSelectFrom = vi.fn(() => ({ where: mockSelectWhere }))
const mockSelect = vi.fn(() => ({ from: mockSelectFrom }))
const mockTxValues = vi.fn(() => ({ onConflictDoNothing: mockOnConflictDoNothing }))
const mockTxInsert = vi.fn(() => ({ values: mockTxValues }))
const mockTxWhere = vi.fn(async () => undefined)
const mockTxDelete = vi.fn(() => ({ where: mockTxWhere }))
const mockTxSelectLimit = vi.fn(async () => [])
const mockTxSelectWhere = vi.fn(() => ({ limit: mockTxSelectLimit }))
const mockTxSelectFrom = vi.fn(() => ({ where: mockTxSelectWhere }))
const mockTxSelect = vi.fn(() => ({ from: mockTxSelectFrom }))
const mockTxSelectDistinct = vi.fn(() => ({ from: mockTxSelectFrom }))
return {
mockAnd: vi.fn((...args: unknown[]) => ({ op: 'and', args })),
mockDelete,
mockEq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })),
mockExecute: vi.fn(async () => [{ count: 0 }]),
mockInsert,
mockNotInArray: vi.fn((...args: unknown[]) => ({ op: 'notInArray', args })),
mockOnConflictDoNothing,
mockSelect,
mockSelectFrom,
mockSelectLimit,
mockSelectWhere,
mockTransaction: vi.fn(async (callback) =>
callback({
delete: mockTxDelete,
insert: mockTxInsert,
select: mockTxSelect,
selectDistinct: mockTxSelectDistinct,
})
),
mockTxDelete,
mockTxInsert,
mockTxSelect,
mockTxSelectDistinct,
mockTxSelectFrom,
mockTxSelectLimit,
mockTxSelectWhere,
mockTxValues,
mockValues,
mockWhere,
mockTxWhere,
}
})
vi.mock('@sim/db', () => ({
db: {
delete: mockDelete,
execute: mockExecute,
insert: mockInsert,
select: mockSelect,
transaction: mockTransaction,
},
}))
vi.mock('@sim/db/schema', () => ({
executionLargeValueDependencies: {
childKey: 'executionLargeValueDependencies.childKey',
parentKey: 'executionLargeValueDependencies.parentKey',
workspaceId: 'executionLargeValueDependencies.workspaceId',
},
executionLargeValueReferences: {
executionId: 'executionLargeValueReferences.executionId',
key: 'executionLargeValueReferences.key',
source: 'executionLargeValueReferences.source',
workspaceId: 'executionLargeValueReferences.workspaceId',
},
executionLargeValues: {
key: 'executionLargeValues.key',
ownerExecutionId: 'executionLargeValues.ownerExecutionId',
workspaceId: 'executionLargeValues.workspaceId',
},
pausedExecutions: {
executionId: 'pausedExecutions.executionId',
status: 'pausedExecutions.status',
},
workflowExecutionLogs: {
executionId: 'workflowExecutionLogs.executionId',
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => ({
warn: vi.fn(),
})),
}))
vi.mock('drizzle-orm', () => ({
and: mockAnd,
eq: mockEq,
inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })),
notInArray: mockNotInArray,
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
}))
import {
addLargeValueReference,
MAX_LARGE_VALUE_REFERENCES_PER_SCOPE,
pruneLargeValueMetadata,
registerLargeValueOwner,
replaceLargeValueReferences,
} from '@/lib/execution/payloads/large-value-metadata'
function largeValueKey(id: string, executionId = 'source-execution'): string {
return `execution/workspace-1/workflow-1/${executionId}/large-value-lv_${id}.json`
}
describe('large value metadata', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('registers valid large value owner metadata', async () => {
const registered = await registerLargeValueOwner({
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123.4,
})
expect(registered).toBe(true)
expect(mockTxInsert).toHaveBeenCalledOnce()
expect(mockTxValues).toHaveBeenCalledWith({
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
ownerExecutionId: 'execution-1',
size: 124,
})
expect(mockOnConflictDoNothing).toHaveBeenCalledOnce()
})
it('skips malformed owner keys', async () => {
const registered = await registerLargeValueOwner({
key: 'execution/workspace-1/workflow-1/other-execution/large-value-lv_abcdefghijkl.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
})
expect(registered).toBe(false)
expect(mockTxInsert).not.toHaveBeenCalled()
})
it('records dependency closure for nested large value refs', async () => {
const directKey = largeValueKey('abcdefghijkl')
const transitiveKey = largeValueKey('mnopqrstuvwx', 'root-execution')
const deepTransitiveKey = largeValueKey('deepqrstuvwx', 'deep-execution')
mockTxSelectLimit
.mockResolvedValueOnce([{ childKey: transitiveKey }])
.mockResolvedValueOnce([{ childKey: deepTransitiveKey }])
.mockResolvedValueOnce([])
const registered = await registerLargeValueOwner(
{
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
[directKey]
)
expect(registered).toBe(true)
expect(mockTxSelectDistinct).toHaveBeenCalledTimes(3)
expect(mockTxValues).toHaveBeenLastCalledWith([
{
parentKey: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
childKey: directKey,
workspaceId: 'workspace-1',
},
{
parentKey: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
childKey: transitiveKey,
workspaceId: 'workspace-1',
},
{
parentKey: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_zyxwvutsrqpo.json',
childKey: deepTransitiveKey,
workspaceId: 'workspace-1',
},
])
})
it('chunks dependency writes instead of emitting one oversized VALUES statement', async () => {
const keys = Array.from({ length: 501 }, (_, index) =>
largeValueKey(`a${index.toString(36).padStart(11, '0')}`)
)
await registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
keys
)
expect(mockTxValues).toHaveBeenCalledTimes(3)
expect(mockTxValues.mock.calls[1]?.[0]).toHaveLength(500)
expect(mockTxValues.mock.calls[2]?.[0]).toHaveLength(1)
})
it('rejects reference sets over the metadata cardinality limit', async () => {
const keys = Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1 }, (_, index) =>
largeValueKey(`b${index.toString(36).padStart(11, '0')}`)
)
await expect(
registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
keys
)
).rejects.toThrow('exceeding the limit')
})
it('limits dependency closure reads to the remaining reference budget', async () => {
const directKey = largeValueKey('a00000000000')
mockTxSelectLimit.mockResolvedValueOnce(
Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) => ({
childKey: largeValueKey(`c${index.toString(36).padStart(11, '0')}`),
}))
)
await expect(
registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
[directKey]
)
).rejects.toThrow('Large value dependency closure exceeds the limit')
expect(mockTxSelectLimit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE)
})
it('filters known dependency children before applying the remaining reference budget', async () => {
const directKeys = Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) =>
largeValueKey(`e${index.toString(36).padStart(11, '0')}`)
)
const knownChildKey = directKeys[1]
const unseenChildKey = largeValueKey('unseenchild1', 'source-execution')
mockTxSelectLimit.mockImplementationOnce(async () => {
const filtersKnownChildren = mockNotInArray.mock.calls.some(
([field, values]) =>
field === 'executionLargeValueDependencies.childKey' &&
Array.isArray(values) &&
values.includes(knownChildKey)
)
return [{ childKey: filtersKnownChildren ? unseenChildKey : knownChildKey }]
})
await expect(
registerLargeValueOwner(
{
key: largeValueKey('zyxwvutsrqpo', 'execution-1'),
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: 123,
},
directKeys
)
).rejects.toThrow('Large value dependency closure exceeds the limit')
expect(mockTxSelectLimit).toHaveBeenCalledWith(1)
})
it('replaces an execution reference set with same-workspace unique keys', async () => {
const matchingKey = largeValueKey('abcdefghijkl')
const otherWorkspaceKey =
'execution/workspace-2/workflow-1/source-execution/large-value-lv_abcdefghijkl.json'
await replaceLargeValueReferences(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
{
a: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'json',
size: 123,
key: matchingKey,
},
duplicate: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'json',
size: 123,
key: matchingKey,
},
ignored: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'json',
size: 123,
key: otherWorkspaceKey,
},
}
)
expect(mockTransaction).toHaveBeenCalledOnce()
expect(mockTxDelete).toHaveBeenCalledOnce()
expect(mockEq).toHaveBeenCalledWith('executionLargeValueReferences.source', 'execution_log')
expect(mockTxValues).toHaveBeenCalledWith([
{
key: matchingKey,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
])
})
it('adds a materialized reference only when the scope is below the reference cap', async () => {
const key = largeValueKey('abcdefghijkl')
await addLargeValueReference(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
key
)
expect(mockSelectLimit).toHaveBeenCalledWith(1)
expect(mockSelectLimit).toHaveBeenCalledWith(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1)
expect(mockValues).toHaveBeenCalledWith({
key,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
})
})
it('rejects materialized references once the scope reaches the reference cap', async () => {
mockSelectLimit.mockResolvedValueOnce([]).mockResolvedValueOnce(
Array.from({ length: MAX_LARGE_VALUE_REFERENCES_PER_SCOPE }, (_, index) => ({
key: largeValueKey(`d${index.toString(36).padStart(11, '0')}`),
}))
)
await expect(
addLargeValueReference(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-2',
source: 'execution_log',
},
largeValueKey('zyxwvutsrqpo')
)
).rejects.toThrow('exceeding the limit')
expect(mockInsert).not.toHaveBeenCalled()
})
it('prunes large value metadata in bounded batches', async () => {
mockExecute
.mockResolvedValueOnce([{ count: 2 }])
.mockResolvedValueOnce([{ count: 3 }])
.mockResolvedValueOnce([{ count: 4 }])
await expect(
pruneLargeValueMetadata({
workspaceIds: ['workspace-1'],
tombstonesDeletedBefore: new Date('2026-01-01T00:00:00Z'),
batchSize: 10,
maxRowsPerTable: 100,
})
).resolves.toEqual({
referencesDeleted: 2,
dependenciesDeleted: 3,
tombstonesDeleted: 4,
})
})
it('uses source-specific liveness when pruning stale references', async () => {
await pruneLargeValueMetadata({
workspaceIds: ['workspace-1'],
tombstonesDeletedBefore: new Date('2026-01-01T00:00:00Z'),
batchSize: 10,
maxRowsPerTable: 100,
})
const [query] = mockExecute.mock.calls[0] ?? []
const sqlText = Array.isArray(query?.strings) ? query.strings.join(' ') : ''
expect(sqlText).toContain("ref.source = 'execution_log'")
expect(sqlText).toContain("ref.source = 'paused_snapshot'")
})
})
@@ -0,0 +1,618 @@
import { db } from '@sim/db'
import {
executionLargeValueDependencies,
executionLargeValueReferences,
executionLargeValues,
pausedExecutions,
workflowExecutionLogs,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, notInArray, sql } from 'drizzle-orm'
import { chunkArray } from '@/lib/cleanup/batch-delete'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
const logger = createLogger('LargeValueMetadata')
type LargeValueMetadataClient = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
export const MAX_LARGE_VALUE_REFERENCES_PER_SCOPE = 5_000
const LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE = 500
const LARGE_VALUE_METADATA_WORKSPACE_CHUNK_SIZE = 50
const LARGE_VALUE_METADATA_PRUNE_BATCH_SIZE = 1_000
const LARGE_VALUE_METADATA_PRUNE_MAX_ROWS_PER_TABLE = 5_000
export const LIVE_PAUSED_REFERENCE_STATUSES = ['paused', 'partially_resumed', 'cancelling'] as const
export interface LargeValueOwner {
key: string
workspaceId: string
workflowId: string
executionId: string
size: number
}
export interface LargeValueReferenceScope {
workspaceId?: string
workflowId?: string | null
executionId?: string
source: 'execution_log' | 'paused_snapshot'
}
interface LargeValueStorageKeyParts {
workspaceId: string
workflowId: string
executionId: string
}
export interface LargeValueMetadataPruneResult {
referencesDeleted: number
dependenciesDeleted: number
tombstonesDeleted: number
}
interface PruneLargeValueMetadataOptions {
workspaceIds: string[]
tombstonesDeletedBefore: Date
batchSize?: number
maxRowsPerTable?: number
}
function parseLargeValueStorageKey(key: string): LargeValueStorageKeyParts | null {
const parts = key.split('/')
if (
parts.length !== 5 ||
parts[0] !== 'execution' ||
!parts[1] ||
!parts[2] ||
!parts[3] ||
!/^large-value-lv_[A-Za-z0-9_-]{12}\.json$/.test(parts[4])
) {
return null
}
return {
workspaceId: parts[1],
workflowId: parts[2],
executionId: parts[3],
}
}
function getBoundedUniqueKeys(keys: string[], label: string): string[] {
const uniqueKeys = Array.from(new Set(keys))
if (uniqueKeys.length > MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) {
throw new Error(
`${label} contains ${uniqueKeys.length} large value references, exceeding the limit of ${MAX_LARGE_VALUE_REFERENCES_PER_SCOPE}`
)
}
return uniqueKeys
}
function getCount(rows: unknown): number {
const [row] = Array.isArray(rows) ? rows : []
if (!row || typeof row !== 'object' || !('count' in row)) {
return 0
}
return Number((row as { count: unknown }).count) || 0
}
export function collectLargeValueReferenceKeys(value: unknown, workspaceId?: string): string[] {
return getBoundedUniqueKeys(
collectLargeValueKeys(value).filter((key) => {
const parsed = parseLargeValueStorageKey(key)
return workspaceId ? parsed?.workspaceId === workspaceId : Boolean(parsed)
}),
'Large value reference set'
)
}
async function getDependencyClosure(
client: LargeValueMetadataClient,
ownerKey: string,
workspaceId: string,
referencedKeys: string[]
): Promise<string[]> {
const directKeys = getBoundedUniqueKeys(
referencedKeys.filter((key) => {
const parsed = parseLargeValueStorageKey(key)
return parsed?.workspaceId === workspaceId && key !== ownerKey
}),
'Large value dependency set'
)
if (directKeys.length === 0) {
return []
}
const closureKeys = new Set(directKeys)
let frontier = directKeys
while (frontier.length > 0) {
const nextFrontier: string[] = []
for (const keyChunk of chunkArray(frontier, LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE)) {
const remainingBudget = MAX_LARGE_VALUE_REFERENCES_PER_SCOPE - closureKeys.size
const rows = await client
.selectDistinct({ childKey: executionLargeValueDependencies.childKey })
.from(executionLargeValueDependencies)
.where(
and(
eq(executionLargeValueDependencies.workspaceId, workspaceId),
inArray(executionLargeValueDependencies.parentKey, keyChunk),
notInArray(executionLargeValueDependencies.childKey, Array.from(closureKeys))
)
)
.limit(remainingBudget + 1)
for (const row of rows) {
if (closureKeys.has(row.childKey)) {
continue
}
closureKeys.add(row.childKey)
nextFrontier.push(row.childKey)
if (closureKeys.size > MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) {
throw new Error(
`Large value dependency closure exceeds the limit of ${MAX_LARGE_VALUE_REFERENCES_PER_SCOPE}`
)
}
}
}
frontier = nextFrontier
}
return Array.from(closureKeys)
}
export async function registerLargeValueOwner(
owner: LargeValueOwner,
referencedKeys: string[] = []
): Promise<boolean> {
if (!Number.isFinite(owner.size) || owner.size <= 0) {
return false
}
const parsed = parseLargeValueStorageKey(owner.key)
if (
!parsed ||
parsed.workspaceId !== owner.workspaceId ||
parsed.workflowId !== owner.workflowId ||
parsed.executionId !== owner.executionId
) {
logger.warn('Skipping large value owner registration for malformed storage key', {
key: owner.key,
workspaceId: owner.workspaceId,
workflowId: owner.workflowId,
executionId: owner.executionId,
})
return false
}
await db.transaction(async (tx) => {
await tx
.insert(executionLargeValues)
.values({
key: owner.key,
workspaceId: owner.workspaceId,
workflowId: owner.workflowId,
ownerExecutionId: owner.executionId,
size: Math.ceil(owner.size),
})
.onConflictDoNothing()
const dependencyKeys = await getDependencyClosure(
tx,
owner.key,
owner.workspaceId,
referencedKeys
)
if (dependencyKeys.length === 0) {
return
}
for (const keyChunk of chunkArray(dependencyKeys, LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE)) {
await tx
.insert(executionLargeValueDependencies)
.values(
keyChunk.map((childKey) => ({
parentKey: owner.key,
childKey,
workspaceId: owner.workspaceId,
}))
)
.onConflictDoNothing()
}
})
return true
}
export async function replaceLargeValueReferencesWithClient(
client: LargeValueMetadataClient,
scope: LargeValueReferenceScope,
value: unknown
): Promise<void> {
if (!scope.workspaceId || !scope.executionId) {
return
}
await replaceLargeValueReferenceKeysWithClient(
client,
scope,
collectLargeValueReferenceKeys(value, scope.workspaceId)
)
}
export async function replaceLargeValueReferenceKeysWithClient(
client: LargeValueMetadataClient,
scope: LargeValueReferenceScope,
referenceKeys: string[]
): Promise<void> {
const { workspaceId, workflowId, executionId, source } = scope
if (!workspaceId || !executionId) {
return
}
const keys = getBoundedUniqueKeys(
referenceKeys.filter((key) => {
const parsed = parseLargeValueStorageKey(key)
return parsed?.workspaceId === workspaceId
}),
'Large value reference set'
)
await client
.delete(executionLargeValueReferences)
.where(
and(
eq(executionLargeValueReferences.workspaceId, workspaceId),
eq(executionLargeValueReferences.executionId, executionId),
eq(executionLargeValueReferences.source, source)
)
)
if (keys.length === 0) {
return
}
for (const keyChunk of chunkArray(keys, LARGE_VALUE_METADATA_WRITE_CHUNK_SIZE)) {
await client
.insert(executionLargeValueReferences)
.values(
keyChunk.map((key) => ({
key,
workspaceId,
workflowId: workflowId ?? null,
executionId,
source,
}))
)
.onConflictDoNothing()
}
}
export async function addLargeValueReference(
scope: LargeValueReferenceScope,
key: string
): Promise<void> {
const { workspaceId, workflowId, executionId, source } = scope
if (!workspaceId || !executionId) {
return
}
const [boundedKey] = getBoundedUniqueKeys(
[key].filter((candidate) => {
const parsed = parseLargeValueStorageKey(candidate)
return parsed?.workspaceId === workspaceId
}),
'Large value reference set'
)
if (!boundedKey) {
return
}
const [existingRef] = await db
.select({ key: executionLargeValueReferences.key })
.from(executionLargeValueReferences)
.where(
and(
eq(executionLargeValueReferences.workspaceId, workspaceId),
eq(executionLargeValueReferences.executionId, executionId),
eq(executionLargeValueReferences.source, source),
eq(executionLargeValueReferences.key, boundedKey)
)
)
.limit(1)
if (existingRef) {
return
}
const existingRefs = await db
.select({ key: executionLargeValueReferences.key })
.from(executionLargeValueReferences)
.where(
and(
eq(executionLargeValueReferences.workspaceId, workspaceId),
eq(executionLargeValueReferences.executionId, executionId),
eq(executionLargeValueReferences.source, source)
)
)
.limit(MAX_LARGE_VALUE_REFERENCES_PER_SCOPE + 1)
if (existingRefs.length >= MAX_LARGE_VALUE_REFERENCES_PER_SCOPE) {
throw new Error(
`Large value reference set contains at least ${existingRefs.length} references, exceeding the limit of ${MAX_LARGE_VALUE_REFERENCES_PER_SCOPE}`
)
}
await db
.insert(executionLargeValueReferences)
.values({
key: boundedKey,
workspaceId,
workflowId: workflowId ?? null,
executionId,
source,
})
.onConflictDoNothing()
}
export async function replaceLargeValueReferences(
scope: LargeValueReferenceScope,
value: unknown
): Promise<void> {
const referenceKeys = scope.workspaceId
? collectLargeValueReferenceKeys(value, scope.workspaceId)
: []
await db.transaction(async (tx) => {
await replaceLargeValueReferenceKeysWithClient(tx, scope, referenceKeys)
})
}
export async function markLargeValuesDeleted(keys: string[]): Promise<void> {
if (keys.length === 0) {
return
}
await db
.update(executionLargeValues)
.set({ deletedAt: new Date() })
.where(inArray(executionLargeValues.key, keys))
}
async function pruneStaleReferences(workspaceIds: string[], batchSize: number): Promise<number> {
const rows = await db.execute<{ count: number }>(sql`
WITH deleted AS (
DELETE FROM ${executionLargeValueReferences} AS ref
WHERE ref.ctid IN (
SELECT ref.ctid
FROM ${executionLargeValueReferences} AS ref
WHERE ref.workspace_id = ANY(${workspaceIds}::text[])
AND (
(
ref.source = 'execution_log'
AND NOT EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS wel
WHERE wel.execution_id = ref.execution_id
)
)
OR (
ref.source = 'paused_snapshot'
AND NOT EXISTS (
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = ref.execution_id
AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
OR ref.source NOT IN ('execution_log', 'paused_snapshot')
)
LIMIT ${batchSize}
)
RETURNING ref.key
)
SELECT count(*)::int AS count FROM deleted
`)
return getCount(rows)
}
async function pruneDeletedParentDependencies(
workspaceIds: string[],
batchSize: number
): Promise<number> {
const rows = await db.execute<{ count: number }>(sql`
WITH deleted AS (
DELETE FROM ${executionLargeValueDependencies} AS dependency
WHERE dependency.ctid IN (
SELECT dependency.ctid
FROM ${executionLargeValueDependencies} AS dependency
WHERE dependency.workspace_id = ANY(${workspaceIds}::text[])
AND (
EXISTS (
SELECT 1
FROM ${executionLargeValues} AS parent_value
WHERE parent_value.key = dependency.parent_key
AND parent_value.deleted_at IS NOT NULL
)
OR NOT EXISTS (
SELECT 1
FROM ${executionLargeValues} AS parent_value
WHERE parent_value.key = dependency.parent_key
)
)
LIMIT ${batchSize}
)
RETURNING dependency.parent_key
)
SELECT count(*)::int AS count FROM deleted
`)
return getCount(rows)
}
async function pruneDeletedLargeValueTombstones(
workspaceIds: string[],
deletedBefore: Date,
batchSize: number
): Promise<number> {
const rows = await db.execute<{ count: number }>(sql`
WITH deleted AS (
DELETE FROM ${executionLargeValues} AS value
WHERE value.ctid IN (
SELECT value.ctid
FROM ${executionLargeValues} AS value
WHERE value.workspace_id = ANY(${workspaceIds}::text[])
AND value.deleted_at IS NOT NULL
AND value.deleted_at < ${deletedBefore}
AND NOT EXISTS (
SELECT 1
FROM ${executionLargeValueDependencies} AS dependency
WHERE dependency.parent_key = value.key
)
LIMIT ${batchSize}
)
RETURNING value.key
)
SELECT count(*)::int AS count FROM deleted
`)
return getCount(rows)
}
export async function pruneLargeValueMetadata({
workspaceIds,
tombstonesDeletedBefore,
batchSize = LARGE_VALUE_METADATA_PRUNE_BATCH_SIZE,
maxRowsPerTable = LARGE_VALUE_METADATA_PRUNE_MAX_ROWS_PER_TABLE,
}: PruneLargeValueMetadataOptions): Promise<LargeValueMetadataPruneResult> {
const result: LargeValueMetadataPruneResult = {
referencesDeleted: 0,
dependenciesDeleted: 0,
tombstonesDeleted: 0,
}
if (workspaceIds.length === 0) return result
for (const workspaceChunk of chunkArray(
workspaceIds,
LARGE_VALUE_METADATA_WORKSPACE_CHUNK_SIZE
)) {
const referencesRemaining = maxRowsPerTable - result.referencesDeleted
if (referencesRemaining > 0) {
result.referencesDeleted += await pruneStaleReferences(
workspaceChunk,
Math.min(batchSize, referencesRemaining)
)
}
const dependenciesRemaining = maxRowsPerTable - result.dependenciesDeleted
if (dependenciesRemaining > 0) {
result.dependenciesDeleted += await pruneDeletedParentDependencies(
workspaceChunk,
Math.min(batchSize, dependenciesRemaining)
)
}
const tombstonesRemaining = maxRowsPerTable - result.tombstonesDeleted
if (tombstonesRemaining > 0) {
result.tombstonesDeleted += await pruneDeletedLargeValueTombstones(
workspaceChunk,
tombstonesDeletedBefore,
Math.min(batchSize, tombstonesRemaining)
)
}
if (
result.referencesDeleted >= maxRowsPerTable &&
result.dependenciesDeleted >= maxRowsPerTable &&
result.tombstonesDeleted >= maxRowsPerTable
) {
break
}
}
return result
}
export function unreferencedLargeValuePredicate() {
return sql`
NOT EXISTS (
SELECT 1
FROM ${executionLargeValueReferences} AS elvr
WHERE elvr.key = ${executionLargeValues.key}
AND (
(
elvr.source = 'execution_log'
AND EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS wel
WHERE wel.execution_id = elvr.execution_id
)
)
OR (
elvr.source = 'paused_snapshot'
AND EXISTS (
SELECT 1
FROM ${pausedExecutions} AS pe
WHERE pe.execution_id = elvr.execution_id
AND pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
)
)
AND NOT EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS owner_wel
WHERE owner_wel.execution_id = ${executionLargeValues.ownerExecutionId}
)
AND NOT EXISTS (
SELECT 1
FROM ${pausedExecutions} AS owner_pe
WHERE owner_pe.execution_id = ${executionLargeValues.ownerExecutionId}
AND owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
AND NOT EXISTS (
SELECT 1
FROM ${executionLargeValueDependencies} AS dependency
INNER JOIN ${executionLargeValues} AS parent_value
ON parent_value.key = dependency.parent_key
AND parent_value.deleted_at IS NULL
WHERE dependency.workspace_id = ${executionLargeValues.workspaceId}
AND dependency.child_key = ${executionLargeValues.key}
AND (
EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS parent_owner_wel
WHERE parent_owner_wel.execution_id = parent_value.owner_execution_id
)
OR EXISTS (
SELECT 1
FROM ${pausedExecutions} AS parent_owner_pe
WHERE parent_owner_pe.execution_id = parent_value.owner_execution_id
AND parent_owner_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
OR EXISTS (
SELECT 1
FROM ${executionLargeValueReferences} AS parent_ref
WHERE parent_ref.key = parent_value.key
AND (
(
parent_ref.source = 'execution_log'
AND EXISTS (
SELECT 1
FROM ${workflowExecutionLogs} AS parent_ref_wel
WHERE parent_ref_wel.execution_id = parent_ref.execution_id
)
)
OR (
parent_ref.source = 'paused_snapshot'
AND EXISTS (
SELECT 1
FROM ${pausedExecutions} AS parent_ref_pe
WHERE parent_ref_pe.execution_id = parent_ref.execution_id
AND parent_ref_pe.status = ANY(${LIVE_PAUSED_REFERENCE_STATUSES}::text[])
)
)
)
)
)
)
`
}
+224 -2
View File
@@ -16,16 +16,29 @@ import {
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
const { mockDownloadFile, mockUploadFile, mockVerifyFileAccess } = vi.hoisted(() => ({
const {
mockAddLargeValueReference,
mockDeleteFileMetadata,
mockDeleteFiles,
mockDownloadFile,
mockRegisterLargeValueOwner,
mockUploadFile,
mockVerifyFileAccess,
} = vi.hoisted(() => ({
mockAddLargeValueReference: vi.fn(),
mockDeleteFileMetadata: vi.fn(),
mockDeleteFiles: vi.fn(),
mockDownloadFile: vi.fn(),
mockRegisterLargeValueOwner: vi.fn(),
mockUploadFile: vi.fn(),
mockVerifyFileAccess: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
uploadFile: mockUploadFile,
deleteFiles: mockDeleteFiles,
downloadFile: mockDownloadFile,
uploadFile: mockUploadFile,
},
}))
@@ -38,11 +51,24 @@ vi.mock('@/app/api/files/authorization', () => ({
verifyFileAccess: mockVerifyFileAccess,
}))
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
addLargeValueReference: mockAddLargeValueReference,
registerLargeValueOwner: mockRegisterLargeValueOwner,
}))
vi.mock('@/lib/uploads/server/metadata', () => ({
deleteFileMetadata: mockDeleteFileMetadata,
}))
describe('large execution payload store', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
mockAddLargeValueReference.mockResolvedValue(undefined)
mockRegisterLargeValueOwner.mockResolvedValue(true)
mockDeleteFiles.mockResolvedValue({ deleted: 1, failed: [] })
mockDeleteFileMetadata.mockResolvedValue(true)
mockVerifyFileAccess.mockResolvedValue(true)
})
@@ -74,6 +100,126 @@ describe('large execution payload store', () => {
customKey: ref.key,
})
)
expect(mockRegisterLargeValueOwner).toHaveBeenCalledWith(
{
key: ref.key,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
size: Buffer.byteLength(json, 'utf8'),
},
[]
)
})
it('cleans up uploaded storage and fails durable writes when owner metadata is not recorded', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockResolvedValueOnce(false)
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
).rejects.toThrow('Failed to persist large execution value metadata')
const key = 'execution/workspace-1/workflow-1/execution-1/large-value-lv_'
expect(mockDeleteFiles.mock.calls[0]?.[0][0]).toContain(key)
expect(mockDeleteFileMetadata).toHaveBeenCalledOnce()
})
it('keeps file metadata when untracked storage deletion reports failure', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockResolvedValueOnce(false)
mockDeleteFiles.mockImplementationOnce(async (keys: string[]) => ({
deleted: 0,
failed: [{ key: keys[0] }],
}))
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
).rejects.toThrow('Failed to persist large execution value metadata')
expect(mockDeleteFiles).toHaveBeenCalledOnce()
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
})
it('does not delete uploaded storage when owner metadata registration throws', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockRejectedValueOnce(new Error('metadata db down'))
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
).rejects.toThrow('metadata db down')
expect(mockDeleteFiles).not.toHaveBeenCalled()
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
})
it('falls back to memory-only refs for non-durable writes when orphan cleanup fails', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockRegisterLargeValueOwner.mockResolvedValueOnce(false)
mockDeleteFiles.mockImplementationOnce(async ([key]) => ({
deleted: 0,
failed: [{ key }],
}))
const ref = await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
})
expect(ref.key).toBeUndefined()
expect(materializeLargeValueRefSync(ref, { executionId: 'execution-1' })).toEqual(value)
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
})
it('passes nested large value refs to owner metadata registration', async () => {
const nestedKey =
'execution/workspace-1/workflow-1/source-execution/large-value-lv_abcdefghijkl.json'
const value = {
nested: {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdefghijkl',
kind: 'object',
size: 123,
key: nestedKey,
},
payload: 'x'.repeat(2048),
}
const json = JSON.stringify(value)
await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
expect(mockRegisterLargeValueOwner).toHaveBeenCalledWith(expect.any(Object), [nestedKey])
})
it('fails durable writes before producing refs when execution context is missing', async () => {
@@ -123,6 +269,60 @@ describe('large execution payload store', () => {
}
)
).resolves.toEqual({ ok: true })
expect(mockAddLargeValueReference).toHaveBeenCalledWith(
{
workspaceId: 'workflow-1',
workflowId: 'workflow-2',
executionId: 'execution-1',
source: 'execution_log',
},
'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json'
)
})
it('records a reference before returning a cached prior-execution value', async () => {
cacheLargeValue(
'lv_CACHEDREF123',
{ cached: true },
16,
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'source-execution',
},
{ recoverable: true }
)
await expect(
materializeLargeValueRef(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_CACHEDREF123',
kind: 'object',
size: 16,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_CACHEDREF123.json',
executionId: 'source-execution',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'consumer-execution',
allowLargeValueWorkflowScope: true,
}
)
).resolves.toEqual({ cached: true })
expect(mockAddLargeValueReference).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'consumer-execution',
source: 'execution_log',
},
'execution/workspace-1/workflow-1/source-execution/large-value-lv_CACHEDREF123.json'
)
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('bounds durable large-value writes', async () => {
@@ -312,6 +512,28 @@ describe('large execution payload store', () => {
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('fails loudly when reference tracking fails before returning cached durable refs', async () => {
const scope = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_TRACKFAIL12',
kind: 'object',
size: 32,
key: 'execution/workspace-1/workflow-1/execution-1/large-value-lv_TRACKFAIL12.json',
executionId: 'execution-1',
} as const
cacheLargeValue(ref.id, { retained: true }, ref.size, scope, { recoverable: true })
mockAddLargeValueReference.mockRejectedValueOnce(new Error('reference cap exceeded'))
await expect(materializeLargeValueRef(ref, scope)).rejects.toThrow('reference cap exceeded')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('enforces maxBytes before returning cached refs', async () => {
const scope = {
workspaceId: 'workspace-1',
+79 -7
View File
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { cacheLargeValue, materializeLargeValueRefSync } from '@/lib/execution/payloads/cache'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
import {
LARGE_VALUE_REF_VERSION,
type LargeValueKind,
@@ -102,6 +103,55 @@ async function persistValue(
}
}
async function registerPersistedValueOwner(
key: string | undefined,
size: number,
referencedKeys: string[],
context: LargeValueStoreContext
): Promise<boolean> {
const { workspaceId, workflowId, executionId } = context
if (!key || !workspaceId || !workflowId || !executionId) {
return false
}
const { registerLargeValueOwner } = await import('@/lib/execution/payloads/large-value-metadata')
return await registerLargeValueOwner(
{
key,
workspaceId,
workflowId,
executionId,
size,
},
referencedKeys
)
}
async function deleteUntrackedPersistedValue(key: string): Promise<boolean> {
try {
const [{ StorageService }, { deleteFileMetadata }] = await Promise.all([
import('@/lib/uploads'),
import('@/lib/uploads/server/metadata'),
])
const result = await StorageService.deleteFiles([key], 'execution')
const deleteFailed = result.failed.some((failed) => failed.key === key)
if (deleteFailed) {
logger.warn('Failed to delete untracked large execution value from storage', {
key,
})
return false
}
await deleteFileMetadata(key)
return true
} catch (error) {
logger.warn('Failed to clean up untracked large execution value', {
key,
error: toError(error).message,
})
return false
}
}
export async function storeLargeValue(
value: unknown,
json: string,
@@ -109,8 +159,19 @@ export async function storeLargeValue(
context: LargeValueStoreContext
): Promise<LargeValueRef> {
assertDurableLargeValueSize(size)
const referencedKeys = collectLargeValueKeys(value)
const id = `lv_${generateShortId(12)}`
const key = await persistValue(id, json, context)
let key = await persistValue(id, json, context)
if (key) {
const registered = await registerPersistedValueOwner(key, size, referencedKeys, context)
if (!registered) {
await deleteUntrackedPersistedValue(key)
if (context.requireDurable) {
throw new Error('Failed to persist large execution value metadata')
}
key = undefined
}
}
const cached = cacheLargeValue(id, value, size, context, { recoverable: Boolean(key) })
if (!key && !cached) {
throw new Error('Cannot retain large execution value without durable storage')
@@ -139,16 +200,27 @@ export async function materializeLargeValueRef(
assertLargeValueRefAccess(ref, context)
assertInlineMaterializationSize(ref.size, context.maxBytes)
const cached = materializeLargeValueRefSync(ref, context)
if (cached !== undefined) {
return cached
if (!ref.key || !isValidLargeValueKey(ref)) {
return materializeLargeValueRefSync(ref, context)
}
if (!ref.key || !isValidLargeValueKey(ref)) {
return undefined
}
const { addLargeValueReference } = await import('@/lib/execution/payloads/large-value-metadata')
await addLargeValueReference(
{
workspaceId: context.workspaceId,
workflowId: context.workflowId,
executionId: context.executionId,
source: 'execution_log',
},
ref.key
)
try {
const cached = materializeLargeValueRefSync(ref, context)
if (cached !== undefined) {
return cached
}
const value = await readLargeValueRefFromStorage(ref, {
workspaceId: context.workspaceId,
workflowId: context.workflowId,
+24 -6
View File
@@ -22,6 +22,10 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { filterForDisplay } from '@/lib/core/utils/display-filters'
import {
collectLargeValueReferenceKeys,
replaceLargeValueReferenceKeysWithClient,
} from '@/lib/execution/payloads/large-value-metadata'
import { emitWorkflowExecutionCompleted } from '@/lib/logs/events'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import type {
@@ -739,11 +743,12 @@ export class ExecutionLogger implements IExecutionLoggerService {
},
executionId
)
const completedExecutionLargeValueKeys = collectLargeValueReferenceKeys(completedExecutionData)
const [updatedLog] = await db.transaction(async (tx) => {
const updatedLog = await db.transaction(async (tx) => {
await setExecutionLogWriteTimeouts(tx)
return tx
const [log] = await tx
.update(workflowExecutionLogs)
.set({
level,
@@ -756,11 +761,24 @@ export class ExecutionLogger implements IExecutionLoggerService {
})
.where(eq(workflowExecutionLogs.executionId, executionId))
.returning()
})
if (!updatedLog) {
throw new Error(`Workflow log not found for execution ${executionId}`)
}
if (!log) {
throw new Error(`Workflow log not found for execution ${executionId}`)
}
await replaceLargeValueReferenceKeysWithClient(
tx,
{
workspaceId: log.workspaceId,
workflowId: log.workflowId,
executionId,
source: 'execution_log',
},
completedExecutionLargeValueKeys
)
return log
})
try {
// Skip workflow lookup if workflow was deleted
@@ -9,6 +9,7 @@ const {
mockUpload,
mockDownload,
mockDelete,
mockDeleteIfExists,
mockGetBlockBlobClient,
mockGetContainerClient,
mockFromConnectionString,
@@ -19,6 +20,7 @@ const {
mockUpload: vi.fn(),
mockDownload: vi.fn(),
mockDelete: vi.fn(),
mockDeleteIfExists: vi.fn(),
mockGetBlockBlobClient: vi.fn(),
mockGetContainerClient: vi.fn(),
mockFromConnectionString: vi.fn(),
@@ -66,6 +68,7 @@ describe('Azure Blob Storage Client', () => {
upload: mockUpload,
download: mockDownload,
delete: mockDelete,
deleteIfExists: mockDeleteIfExists,
url: 'https://test.blob.core.windows.net/container/test-file',
})
@@ -181,12 +184,12 @@ describe('Azure Blob Storage Client', () => {
it('should delete a file from Azure Blob Storage', async () => {
const testKey = 'test-file-key'
mockDelete.mockResolvedValueOnce({})
mockDeleteIfExists.mockResolvedValueOnce({})
await deleteFromBlob(testKey)
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
expect(mockDelete).toHaveBeenCalled()
expect(mockDeleteIfExists).toHaveBeenCalled()
})
})
@@ -435,7 +435,7 @@ export async function deleteFromBlob(key: string, customConfig?: BlobConfig): Pr
const containerClient = blobServiceClient.getContainerClient(containerName)
const blockBlobClient = containerClient.getBlockBlobClient(key)
await blockBlobClient.delete()
await blockBlobClient.deleteIfExists()
}
/**
@@ -13,6 +13,10 @@ import {
resetExecutionStreamBuffer,
type TerminalExecutionStreamStatus,
} from '@/lib/execution/event-buffer'
import {
collectLargeValueReferenceKeys,
replaceLargeValueReferenceKeysWithClient,
} from '@/lib/execution/payloads/large-value-metadata'
import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
@@ -48,6 +52,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function parseSnapshotForReferenceTracking(snapshotSeed: SerializedSnapshot): unknown {
try {
return { ...snapshotSeed, snapshot: JSON.parse(snapshotSeed.snapshot) }
} catch {
return snapshotSeed
}
}
function getSnapshotWorkspaceId(snapshotValue: unknown): string | undefined {
if (!isRecord(snapshotValue)) return undefined
const metadata = snapshotValue.metadata
if (!isRecord(metadata)) return undefined
return typeof metadata.workspaceId === 'string' ? metadata.workspaceId : undefined
}
function isResumablePausedStatus(status: string): boolean {
return RESUMABLE_PAUSED_STATUSES.includes(status as (typeof RESUMABLE_PAUSED_STATUSES)[number])
}
@@ -169,6 +188,13 @@ export function computeEarliestResumeAt(
export class PauseResumeManager {
static async persistPauseResult(args: PersistPauseResultArgs): Promise<void> {
const { workflowId, executionId, pausePoints, snapshotSeed, executorUserId } = args
const snapshotReferenceValue = parseSnapshotForReferenceTracking(snapshotSeed)
const snapshotWorkspaceId = getSnapshotWorkspaceId(
isRecord(snapshotReferenceValue) ? snapshotReferenceValue.snapshot : undefined
)
const snapshotReferenceKeys = snapshotWorkspaceId
? collectLargeValueReferenceKeys(snapshotReferenceValue, snapshotWorkspaceId)
: []
const pausePointsRecord = pausePoints.reduce<Record<string, any>>((acc, point) => {
acc[point.contextId] = {
@@ -220,6 +246,18 @@ export class PauseResumeManager {
updatedAt: now,
nextResumeAt,
})
if (snapshotWorkspaceId) {
await replaceLargeValueReferenceKeysWithClient(
tx,
{
workspaceId: snapshotWorkspaceId,
workflowId,
executionId,
source: 'paused_snapshot',
},
snapshotReferenceKeys
)
}
return
}
@@ -264,6 +302,19 @@ export class PauseResumeManager {
nextResumeAt: mergedNextResumeAt,
})
.where(eq(pausedExecutions.id, existing.id))
if (snapshotWorkspaceId) {
await replaceLargeValueReferenceKeysWithClient(
tx,
{
workspaceId: snapshotWorkspaceId,
workflowId,
executionId,
source: 'paused_snapshot',
},
snapshotReferenceKeys
)
}
})
await PauseResumeManager.processQueuedResumes(executionId, workflowId)
@@ -1568,14 +1619,34 @@ export class PauseResumeManager {
snapshot: JSON.stringify(snapshotData),
triggerIds: currentSnapshot.triggerIds,
}
const snapshotWorkspaceId = getSnapshotWorkspaceId(snapshotData)
const snapshotReferenceValue = { ...updatedSnapshot, snapshot: snapshotData }
const snapshotReferenceKeys = snapshotWorkspaceId
? collectLargeValueReferenceKeys(snapshotReferenceValue, snapshotWorkspaceId)
: []
await db
.update(pausedExecutions)
.set({
executionSnapshot: updatedSnapshot,
updatedAt: new Date(),
})
.where(eq(pausedExecutions.id, pausedExecutionId))
await db.transaction(async (tx) => {
await tx
.update(pausedExecutions)
.set({
executionSnapshot: updatedSnapshot,
updatedAt: new Date(),
})
.where(eq(pausedExecutions.id, pausedExecutionId))
if (snapshotWorkspaceId) {
await replaceLargeValueReferenceKeysWithClient(
tx,
{
workspaceId: snapshotWorkspaceId,
workflowId: pausedExecution.workflowId,
executionId: pausedExecution.executionId,
source: 'paused_snapshot',
},
snapshotReferenceKeys
)
}
})
logger.info('Updated snapshot after resume', {
pausedExecutionId,
@@ -0,0 +1,40 @@
CREATE TYPE "public"."execution_large_value_reference_source" AS ENUM('execution_log', 'paused_snapshot');--> statement-breakpoint
CREATE TABLE "execution_large_value_dependencies" (
"parent_key" text NOT NULL,
"child_key" text NOT NULL,
"workspace_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "execution_large_value_dependencies_parent_key_child_key_pk" PRIMARY KEY("parent_key","child_key")
);
--> statement-breakpoint
CREATE TABLE "execution_large_value_references" (
"key" text NOT NULL,
"execution_id" text NOT NULL,
"source" "execution_large_value_reference_source" NOT NULL,
"workspace_id" text NOT NULL,
"workflow_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "execution_large_value_references_key_execution_id_source_pk" PRIMARY KEY("key","execution_id","source")
);
--> statement-breakpoint
CREATE TABLE "execution_large_values" (
"key" text PRIMARY KEY NOT NULL,
"workspace_id" text NOT NULL,
"workflow_id" text,
"owner_execution_id" text NOT NULL,
"size" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "execution_large_value_dependencies" ADD CONSTRAINT "execution_large_value_dependencies_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_large_value_references" ADD CONSTRAINT "execution_large_value_references_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_large_value_references" ADD CONSTRAINT "execution_large_value_references_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_large_values" ADD CONSTRAINT "execution_large_values_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_large_values" ADD CONSTRAINT "execution_large_values_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "execution_large_value_dependencies_workspace_parent_key_idx" ON "execution_large_value_dependencies" USING btree ("workspace_id","parent_key");--> statement-breakpoint
CREATE INDEX "execution_large_value_dependencies_workspace_child_key_idx" ON "execution_large_value_dependencies" USING btree ("workspace_id","child_key");--> statement-breakpoint
CREATE INDEX "execution_large_value_references_workspace_execution_source_idx" ON "execution_large_value_references" USING btree ("workspace_id","execution_id","source");--> statement-breakpoint
CREATE INDEX "execution_large_values_owner_execution_id_idx" ON "execution_large_values" USING btree ("owner_execution_id");--> statement-breakpoint
CREATE INDEX "execution_large_values_cleanup_idx" ON "execution_large_values" USING btree ("workspace_id","created_at","key") WHERE "execution_large_values"."deleted_at" IS NULL;--> statement-breakpoint
CREATE INDEX "execution_large_values_tombstone_cleanup_idx" ON "execution_large_values" USING btree ("workspace_id","deleted_at","key") WHERE "execution_large_values"."deleted_at" IS NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -1478,6 +1478,13 @@
"when": 1779398164637,
"tag": "0211_breezy_cloak",
"breakpoints": true
},
{
"idx": 212,
"version": "7",
"when": 1779472552512,
"tag": "0212_sturdy_guardsmen",
"breakpoints": true
}
]
}
+74
View File
@@ -367,6 +367,80 @@ export const workflowExecutionLogs = pgTable(
})
)
export const executionLargeValueReferenceSourceEnum = pgEnum(
'execution_large_value_reference_source',
['execution_log', 'paused_snapshot']
)
export const executionLargeValues = pgTable(
'execution_large_values',
{
key: text('key').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
ownerExecutionId: text('owner_execution_id').notNull(),
size: integer('size').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
deletedAt: timestamp('deleted_at'),
},
(table) => ({
ownerExecutionIdIdx: index('execution_large_values_owner_execution_id_idx').on(
table.ownerExecutionId
),
cleanupIdx: index('execution_large_values_cleanup_idx')
.on(table.workspaceId, table.createdAt, table.key)
.where(sql`${table.deletedAt} IS NULL`),
tombstoneCleanupIdx: index('execution_large_values_tombstone_cleanup_idx')
.on(table.workspaceId, table.deletedAt, table.key)
.where(sql`${table.deletedAt} IS NOT NULL`),
})
)
export const executionLargeValueReferences = pgTable(
'execution_large_value_references',
{
key: text('key').notNull(),
executionId: text('execution_id').notNull(),
source: executionLargeValueReferenceSourceEnum('source').notNull(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.key, table.executionId, table.source] }),
workspaceExecutionSourceIdx: index(
'execution_large_value_references_workspace_execution_source_idx'
).on(table.workspaceId, table.executionId, table.source),
})
)
export const executionLargeValueDependencies = pgTable(
'execution_large_value_dependencies',
{
parentKey: text('parent_key').notNull(),
childKey: text('child_key').notNull(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.parentKey, table.childKey] }),
workspaceParentKeyIdx: index('execution_large_value_dependencies_workspace_parent_key_idx').on(
table.workspaceId,
table.parentKey
),
workspaceChildKeyIdx: index('execution_large_value_dependencies_workspace_child_key_idx').on(
table.workspaceId,
table.childKey
),
})
)
export const pausedExecutions = pgTable(
'paused_executions',
{
+17 -12
View File
@@ -234,20 +234,25 @@ export const dbChainMock = {
* Creates a mock database connection.
*/
export function createMockDb() {
const fromBuilder = () => ({
where: vi.fn(() => ({
limit: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
leftJoin: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
})),
innerJoin: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
})),
})
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
leftJoin: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
})),
innerJoin: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
})),
})),
from: vi.fn(fromBuilder),
})),
selectDistinct: vi.fn(() => ({
from: vi.fn(fromBuilder),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
+23
View File
@@ -157,6 +157,29 @@ export const schemaMock = {
files: 'files',
createdAt: 'createdAt',
},
executionLargeValues: {
key: 'key',
workspaceId: 'workspaceId',
workflowId: 'workflowId',
ownerExecutionId: 'ownerExecutionId',
size: 'size',
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
executionLargeValueReferences: {
key: 'key',
executionId: 'executionId',
source: 'source',
workspaceId: 'workspaceId',
workflowId: 'workflowId',
createdAt: 'createdAt',
},
executionLargeValueDependencies: {
parentKey: 'parentKey',
childKey: 'childKey',
workspaceId: 'workspaceId',
createdAt: 'createdAt',
},
pausedExecutions: {
id: 'id',
workflowId: 'workflowId',