chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,41 @@
import { collectUserFileKeys } from '@/lib/core/utils/user-file'
import { collectLargeValueKeys } from '@/lib/execution/payloads/large-execution-value'
export interface ExactAccessKeyContext {
largeValueKeys?: string[]
fileKeys?: string[]
}
export function mergeUniqueKeys(target: string[], source: readonly string[]): void {
if (source.length === 0) {
return
}
const existingKeys = new Set(target)
for (const key of source) {
if (!existingKeys.has(key)) {
existingKeys.add(key)
target.push(key)
}
}
}
export function mergeLargeValueKeys(context: ExactAccessKeyContext, keys: readonly string[]): void {
if (keys.length === 0) {
return
}
context.largeValueKeys ??= []
mergeUniqueKeys(context.largeValueKeys, keys)
}
export function mergeFileKeys(context: ExactAccessKeyContext, keys: readonly string[]): void {
if (keys.length === 0) {
return
}
context.fileKeys ??= []
mergeUniqueKeys(context.fileKeys, keys)
}
export function recordMaterializedAccessKeys(context: ExactAccessKeyContext, value: unknown): void {
mergeLargeValueKeys(context, collectLargeValueKeys(value))
mergeFileKeys(context, collectUserFileKeys(value))
}
+173
View File
@@ -0,0 +1,173 @@
import {
getLargeValueMaterializationError,
isLargeValueRef,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
const FALLBACK_TTL_MS = 15 * 60 * 1000
const MAX_IN_MEMORY_BYTES = 256 * 1024 * 1024
interface LargeValueCacheScope {
workspaceId?: string
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
allowLargeValueWorkflowScope?: boolean
}
const inMemoryValues = new Map<
string,
{
value: unknown
size: number
expiresAt: number
scope?: LargeValueCacheScope
recoverable: boolean
}
>()
let inMemoryBytes = 0
export function clearLargeValueCacheForTests(): void {
inMemoryValues.clear()
inMemoryBytes = 0
}
function cleanupExpiredValues(now = Date.now()): void {
for (const [id, entry] of inMemoryValues.entries()) {
if (entry.expiresAt <= now) {
inMemoryValues.delete(id)
inMemoryBytes -= entry.size
}
}
}
export function cacheLargeValue(
id: string,
value: unknown,
size: number,
scope?: LargeValueCacheScope,
options: { recoverable?: boolean } = {}
): boolean {
if (size > MAX_IN_MEMORY_BYTES) {
return false
}
cleanupExpiredValues()
const existing = inMemoryValues.get(id)
if (existing) {
inMemoryValues.delete(id)
inMemoryBytes -= existing.size
}
while (inMemoryBytes + size > MAX_IN_MEMORY_BYTES && inMemoryValues.size > 0) {
const oldestRecoverableId = Array.from(inMemoryValues.entries()).find(
([, entry]) => entry.recoverable
)?.[0]
if (!oldestRecoverableId) break
const oldest = inMemoryValues.get(oldestRecoverableId)
inMemoryValues.delete(oldestRecoverableId)
inMemoryBytes -= oldest?.size ?? 0
}
if (inMemoryBytes + size > MAX_IN_MEMORY_BYTES) {
if (existing) {
inMemoryValues.set(id, existing)
inMemoryBytes += existing.size
}
return false
}
inMemoryValues.set(id, {
value,
size,
scope,
recoverable: options.recoverable ?? false,
expiresAt: Date.now() + FALLBACK_TTL_MS,
})
inMemoryBytes += size
return true
}
function scopeMatchesRef(
ref: LargeValueRef,
cachedScope: LargeValueCacheScope | undefined,
callerScope?: LargeValueCacheScope
): boolean {
if (!cachedScope?.executionId) {
return false
}
if (ref.executionId && ref.executionId !== cachedScope.executionId) {
return false
}
if (!callerScope) {
return Boolean(ref.key) && (!ref.executionId || ref.executionId === cachedScope.executionId)
}
const allowedExecutionIds = new Set([
callerScope.executionId,
...(callerScope.largeValueExecutionIds ?? []),
])
if (ref.key && callerScope.largeValueKeys?.includes(ref.key)) {
return true
}
const workflowScopeAllowed =
callerScope.allowLargeValueWorkflowScope &&
callerScope.workspaceId === cachedScope.workspaceId &&
callerScope.workflowId === cachedScope.workflowId
return allowedExecutionIds.has(cachedScope.executionId) || Boolean(workflowScopeAllowed)
}
export function materializeLargeValueRefSync(
ref: LargeValueRef,
callerScope?: LargeValueCacheScope
): unknown {
cleanupExpiredValues()
const cached = inMemoryValues.get(ref.id)
if (!cached || !scopeMatchesRef(ref, cached.scope, callerScope)) {
return undefined
}
return cached.value
}
export function materializeLargeValueRefSyncOrThrow(
ref: LargeValueRef,
callerScope?: LargeValueCacheScope
): unknown {
const materialized = materializeLargeValueRefSync(ref, callerScope)
if (materialized === undefined) {
throw getLargeValueMaterializationError(ref)
}
return materialized
}
export function materializeLargeValueRefsSync(
value: unknown,
seen = new WeakSet<object>()
): unknown {
if (isLargeValueRef(value)) {
return materializeLargeValueRefsSync(materializeLargeValueRefSyncOrThrow(value), seen)
}
if (!value || typeof value !== 'object') {
return value
}
if (seen.has(value)) {
return value
}
seen.add(value)
if (Array.isArray(value)) {
return value.map((item) => materializeLargeValueRefsSync(item, seen))
}
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [
key,
materializeLargeValueRefsSync(entryValue, seen),
])
)
}
@@ -0,0 +1,144 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockMaterializeLargeValueRef } = vi.hoisted(() => ({
mockMaterializeLargeValueRef: vi.fn(),
}))
vi.mock('@/lib/execution/payloads/store', () => ({
materializeLargeValueRef: mockMaterializeLargeValueRef,
}))
import { warmLargeValueRefs } from '@/lib/execution/payloads/hydration'
describe('warmLargeValueRefs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not warm manifest chunks before explicit navigation', async () => {
const chunkRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 16,
executionId: 'execution-1',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: chunkRef,
count: 1,
byteSize: 16,
},
],
preview: [],
}
await warmLargeValueRefs({ issues: manifest }, { executionId: 'execution-1' })
expect(mockMaterializeLargeValueRef).not.toHaveBeenCalled()
})
it('records exact keys discovered while warming manifest preview refs', async () => {
const previewRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 16,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution',
}
const nestedRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'object',
size: 16,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_MNOPQRSTUVWX.json',
executionId: 'source-execution',
}
const file = {
id: 'file-1',
name: 'nested.txt',
key: 'execution/workspace-1/workflow-1/source-execution/nested.txt',
url: '/api/files/serve/execution/workspace-1/workflow-1/source-execution/nested.txt?context=execution',
size: 5,
type: 'text/plain',
context: 'execution',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 16,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_CHUNKREF0001',
kind: 'array',
size: 16,
executionId: 'source-execution',
},
count: 1,
byteSize: 16,
},
],
preview: [previewRef],
}
const context = {
executionId: 'execution-1',
largeValueKeys: [] as string[],
fileKeys: [] as string[],
}
mockMaterializeLargeValueRef.mockResolvedValueOnce([{ nestedRef, file }])
await warmLargeValueRefs({ issues: manifest }, context)
expect(mockMaterializeLargeValueRef).toHaveBeenCalledWith(previewRef, context)
expect(context.largeValueKeys).toEqual([nestedRef.key])
expect(context.fileKeys).toEqual([file.key])
})
it('warms manifest preview refs without exposing chunk internals as navigable metadata', async () => {
const previewRef = {
__simLargeValueRef: true,
version: 1,
id: 'lv_PREVIEWREF01',
kind: 'object',
size: 16,
executionId: 'execution-1',
}
const manifest = {
__simLargeArrayManifest: true,
version: 2,
kind: 'array',
totalCount: 0,
chunkCount: 0,
byteSize: 0,
chunks: [],
preview: [previewRef],
}
mockMaterializeLargeValueRef.mockResolvedValueOnce({ key: 'SIM-1' })
await warmLargeValueRefs({ issues: manifest }, { executionId: 'execution-1' })
expect(mockMaterializeLargeValueRef).toHaveBeenCalledWith(previewRef, {
executionId: 'execution-1',
})
})
})
@@ -0,0 +1,56 @@
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import {
type LargeValueStoreContext,
materializeLargeValueRef,
} from '@/lib/execution/payloads/store'
function withLocalMaterializedKeys(
context: LargeValueStoreContext,
materializedValue: unknown
): LargeValueStoreContext {
recordMaterializedAccessKeys(context, materializedValue)
return {
...context,
largeValueKeys: context.largeValueKeys,
fileKeys: context.fileKeys,
}
}
export async function warmLargeValueRefs(
value: unknown,
context: LargeValueStoreContext = {},
seen = new WeakSet<object>()
): Promise<void> {
if (!value || typeof value !== 'object') {
return
}
if (isLargeValueRef(value)) {
const materialized = await materializeLargeValueRef(value, context)
await warmLargeValueRefs(materialized, withLocalMaterializedKeys(context, materialized), seen)
return
}
if (seen.has(value)) {
return
}
seen.add(value)
if (isLargeArrayManifest(value)) {
await warmLargeValueRefs(value.preview, context, seen)
return
}
if (Array.isArray(value)) {
for (const item of value) {
await warmLargeValueRefs(item, context, seen)
}
return
}
for (const entryValue of Object.values(value)) {
await warmLargeValueRefs(entryValue, context, seen)
}
}
@@ -0,0 +1,167 @@
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
import {
isLargeArrayManifest,
materializeLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
getLargeValueMaterializationError,
isLargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import {
assertInlineMaterializationSize,
type ExecutionMaterializationContext,
MAX_INLINE_MATERIALIZATION_BYTES,
} from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
interface InlineMaterializationOptions {
maxBytes?: number
}
type InlineMaterializationMemo = WeakMap<object, Promise<unknown>>
interface MaterializedInlineValue {
value: unknown
byteLength: number | undefined
}
export function getInlineJsonByteLength(value: unknown): number | undefined {
const json = JSON.stringify(value)
return json === undefined ? undefined : Buffer.byteLength(json, 'utf8')
}
function getArrayItemByteLength(value: MaterializedInlineValue): number {
return value.byteLength ?? Buffer.byteLength('null', 'utf8')
}
function getObjectEntryByteLength(key: string, value: MaterializedInlineValue): number | undefined {
if (value.byteLength === undefined) {
return undefined
}
return Buffer.byteLength(JSON.stringify(key), 'utf8') + 1 + value.byteLength
}
function withMaterializedAccessKeys(
context: ExecutionMaterializationContext | undefined,
materializedValue: unknown
): ExecutionMaterializationContext | undefined {
if (!context) {
return context
}
recordMaterializedAccessKeys(context, materializedValue)
return {
...context,
largeValueKeys: context.largeValueKeys,
fileKeys: context.fileKeys,
}
}
export async function materializeInlineExecutionValue(
value: unknown,
context: ExecutionMaterializationContext | undefined,
options: InlineMaterializationOptions = {}
): Promise<unknown> {
const materialized = await materializeInlineExecutionValueWithinBudget(
value,
context,
options.maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES,
new WeakMap<object, Promise<unknown>>()
)
return materialized.value
}
async function materializeInlineExecutionValueWithinBudget(
value: unknown,
context: ExecutionMaterializationContext | undefined,
maxBytes: number,
memo: InlineMaterializationMemo
): Promise<MaterializedInlineValue> {
if (isLargeArrayManifest(value)) {
assertInlineMaterializationSize(value.byteSize, maxBytes)
const materialized = await materializeLargeArrayManifest(value, {
...context,
maxBytes,
})
return materializeInlineExecutionValueWithinBudget(
materialized,
withMaterializedAccessKeys(context, materialized),
maxBytes,
memo
)
}
if (isLargeValueRef(value)) {
assertInlineMaterializationSize(value.size, maxBytes)
const materialized = await materializeLargeValueRef(value, {
...context,
maxBytes,
})
if (materialized === undefined) {
throw getLargeValueMaterializationError(value)
}
return materializeInlineExecutionValueWithinBudget(
materialized,
withMaterializedAccessKeys(context, materialized),
maxBytes,
memo
)
}
if (!value || typeof value !== 'object') {
const valueBytes = getInlineJsonByteLength(value)
if (valueBytes !== undefined) {
assertInlineMaterializationSize(valueBytes, maxBytes)
}
return { value, byteLength: valueBytes }
}
const cached = memo.get(value)
if (cached) {
return { value: await cached, byteLength: 0 }
}
if (Array.isArray(value)) {
const result: unknown[] = []
memo.set(value, Promise.resolve(result))
let usedBytes = Buffer.byteLength('[]', 'utf8')
for (const item of value) {
const commaBytes = result.length > 0 ? 1 : 0
const remainingBytes = maxBytes - usedBytes - commaBytes
assertInlineMaterializationSize(0, remainingBytes)
const materializedItem = await materializeInlineExecutionValueWithinBudget(
item,
context,
remainingBytes,
memo
)
const itemBytes = getArrayItemByteLength(materializedItem)
usedBytes += commaBytes + itemBytes
assertInlineMaterializationSize(usedBytes, maxBytes)
result.push(materializedItem.value)
}
return { value: result, byteLength: usedBytes }
}
const result: Record<string, unknown> = {}
memo.set(value, Promise.resolve(result))
let usedBytes = Buffer.byteLength('{}', 'utf8')
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
const keyBytes = Buffer.byteLength(JSON.stringify(key), 'utf8') + 1
const commaBytes = Object.keys(result).length > 0 ? 1 : 0
const remainingBytes = maxBytes - usedBytes - commaBytes - keyBytes
assertInlineMaterializationSize(0, remainingBytes)
const materializedEntryValue = await materializeInlineExecutionValueWithinBudget(
entryValue,
context,
remainingBytes,
memo
)
const entryBytes = getObjectEntryByteLength(key, materializedEntryValue)
if (entryBytes !== undefined) {
usedBytes += commaBytes + entryBytes
assertInlineMaterializationSize(usedBytes, maxBytes)
}
result[key] = materializedEntryValue.value
}
return { value: result, byteLength: usedBytes }
}
@@ -0,0 +1,80 @@
import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
export const LARGE_ARRAY_MANIFEST_MARKER = '__simLargeArrayManifest'
export const LARGE_ARRAY_MANIFEST_VERSION = 2
export const LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES = 16 * 1024
export interface LargeArrayManifest {
[LARGE_ARRAY_MANIFEST_MARKER]: true
version: typeof LARGE_ARRAY_MANIFEST_VERSION
kind: 'array'
totalCount: number
chunkCount: number
byteSize: number
chunks: LargeArrayManifestChunk[]
preview: unknown[]
}
export interface LargeArrayManifestChunk {
ref: LargeValueRef
count: number
byteSize: number
}
function isValidCount(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
function isValidByteSize(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function isValidPreview(value: unknown): value is unknown[] {
return Array.isArray(value) && value.length <= 3
}
export function isLargeArrayManifest(value: unknown): value is LargeArrayManifest {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Record<string, unknown>
if (
candidate[LARGE_ARRAY_MANIFEST_MARKER] !== true ||
candidate.version !== LARGE_ARRAY_MANIFEST_VERSION ||
candidate.kind !== 'array' ||
!isValidCount(candidate.totalCount) ||
!isValidCount(candidate.chunkCount) ||
!isValidByteSize(candidate.byteSize) ||
!Array.isArray(candidate.chunks) ||
!isValidPreview(candidate.preview) ||
candidate.chunkCount !== candidate.chunks.length
) {
return false
}
let totalCount = 0
let byteSize = 0
for (const chunk of candidate.chunks) {
if (!chunk || typeof chunk !== 'object') {
return false
}
const chunkRecord = chunk as Record<string, unknown>
if (
!isLargeValueRef(chunkRecord.ref) ||
!isValidCount(chunkRecord.count) ||
chunkRecord.count <= 0 ||
!isValidByteSize(chunkRecord.byteSize) ||
chunkRecord.byteSize <= 0 ||
chunkRecord.byteSize !== chunkRecord.ref.size
) {
return false
}
totalCount += chunkRecord.count
byteSize += chunkRecord.ref.size
}
return candidate.totalCount === totalCount && candidate.byteSize === byteSize
}
@@ -0,0 +1,183 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
import {
appendLargeArrayManifest,
createLargeArrayManifest,
isLargeArrayManifest,
materializeLargeArrayManifest,
readLargeArrayManifestSlice,
} from '@/lib/execution/payloads/large-array-manifest'
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
const { mockDownloadFile, mockUploadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
StorageService: {
downloadFile: mockDownloadFile,
uploadFile: mockUploadFile,
},
}))
const TEST_CONTEXT = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
}
describe('large array manifests', () => {
beforeEach(() => {
vi.clearAllMocks()
clearLargeValueCacheForTests()
mockDownloadFile.mockReset()
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
})
it('creates a manifest with one chunk for the first page', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }, { id: 2 }], TEST_CONTEXT)
expect(isLargeArrayManifest(manifest)).toBe(true)
expect(manifest).toMatchObject({
__simLargeArrayManifest: true,
kind: 'array',
totalCount: 2,
chunkCount: 1,
preview: [{ id: 1 }, { id: 2 }],
})
expect(manifest.chunks).toEqual([
expect.objectContaining({ count: 2, byteSize: expect.any(Number) }),
])
expect(mockUploadFile).toHaveBeenCalledTimes(1)
})
it('appends pages without materializing previous chunks', async () => {
const firstPage = await createLargeArrayManifest([{ id: 1 }], TEST_CONTEXT)
clearLargeValueCacheForTests()
const manifest = await appendLargeArrayManifest(firstPage, [{ id: 2 }, { id: 3 }], TEST_CONTEXT)
expect(manifest.totalCount).toBe(3)
expect(manifest.chunkCount).toBe(2)
expect(manifest.chunks).toHaveLength(2)
expect(mockUploadFile).toHaveBeenCalledTimes(2)
})
it('reads a bounded slice from only requested positions', async () => {
let manifest = await createLargeArrayManifest([{ id: 1 }, { id: 2 }], TEST_CONTEXT)
manifest = await appendLargeArrayManifest(manifest, [{ id: 3 }, { id: 4 }], TEST_CONTEXT)
await expect(readLargeArrayManifestSlice(manifest, 1, 2, TEST_CONTEXT)).resolves.toEqual([
{ id: 2 },
{ id: 3 },
])
})
it('splits oversized pages into bounded chunks', async () => {
const manifest = await createLargeArrayManifest(
[
{ id: 1, payload: 'x'.repeat(80) },
{ id: 2, payload: 'y'.repeat(80) },
{ id: 3, payload: 'z'.repeat(80) },
],
{ ...TEST_CONTEXT, chunkTargetBytes: 128 }
)
expect(manifest.totalCount).toBe(3)
expect(manifest.chunkCount).toBe(3)
expect(manifest.chunks.map((chunk) => chunk.count)).toEqual([1, 1, 1])
expect(mockUploadFile).toHaveBeenCalledTimes(3)
})
it('chunks arrays with undefined entries using JSON array semantics', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }, undefined, { id: 3 }], {
...TEST_CONTEXT,
chunkTargetBytes: 16,
})
expect(manifest.totalCount).toBe(3)
await expect(readLargeArrayManifestSlice(manifest, 1, 1, TEST_CONTEXT)).resolves.toEqual([
undefined,
])
})
it('reports non-serializable chunk values with a manifest-specific error', async () => {
const circular: Record<string, unknown> = { id: 1 }
circular.self = circular
await expect(createLargeArrayManifest([circular], TEST_CONTEXT)).rejects.toThrow(
'Large array manifest chunks must be JSON-serializable.'
)
await expect(createLargeArrayManifest([{ id: 1n }], TEST_CONTEXT)).rejects.toThrow(
'Large array manifest chunks must be JSON-serializable.'
)
})
it('skips preceding chunks without materializing them for bounded reads', async () => {
let manifest = await createLargeArrayManifest([{ id: 1 }, { id: 2 }], TEST_CONTEXT)
manifest = await appendLargeArrayManifest(manifest, [{ id: 3 }, { id: 4 }], TEST_CONTEXT)
clearLargeValueCacheForTests()
mockDownloadFile.mockImplementation(async ({ key }) => {
expect(key).toBe(manifest.chunks[1].ref.key)
return Buffer.from(JSON.stringify([{ id: 3 }, { id: 4 }]))
})
await expect(readLargeArrayManifestSlice(manifest, 2, 1, TEST_CONTEXT)).resolves.toEqual([
{ id: 3 },
])
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
})
it('bounds full materialization by byte size', async () => {
const manifest = await createLargeArrayManifest([{ id: 1, payload: 'x'.repeat(2048) }], {
...TEST_CONTEXT,
})
await expect(
materializeLargeArrayManifest(manifest, { ...TEST_CONTEXT, maxBytes: 256 })
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('rejects manifests with understated aggregate byte size', async () => {
const manifest = await createLargeArrayManifest([{ id: 1, payload: 'x'.repeat(2048) }], {
...TEST_CONTEXT,
})
await expect(
materializeLargeArrayManifest({ ...manifest, byteSize: 1 }, { ...TEST_CONTEXT })
).rejects.toThrow('Invalid large array manifest')
})
it('rejects manifests whose chunk count does not match materialized data', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }], TEST_CONTEXT)
const forgedManifest = {
...manifest,
totalCount: 2,
chunks: [{ ...manifest.chunks[0], count: 2 }],
}
expect(isLargeArrayManifest(forgedManifest)).toBe(true)
await expect(readLargeArrayManifestSlice(forgedManifest, 1, 1, TEST_CONTEXT)).rejects.toThrow(
'Large array manifest chunk count does not match materialized data'
)
})
it('does not serialize preview metadata during hot type-guard checks', async () => {
const manifest = await createLargeArrayManifest([{ id: 1 }], TEST_CONTEXT)
const stringifySpy = vi.spyOn(JSON, 'stringify')
expect(
isLargeArrayManifest({
...manifest,
preview: [{ payload: 'x'.repeat(20 * 1024) }],
})
).toBe(true)
expect(stringifySpy).not.toHaveBeenCalled()
stringifySpy.mockRestore()
})
})
@@ -0,0 +1,250 @@
import {
isLargeArrayManifest,
LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES,
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
type LargeArrayManifestChunk,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import {
assertInlineMaterializationSize,
MAX_INLINE_MATERIALIZATION_BYTES,
} from '@/lib/execution/payloads/materialization.server'
import type { LargeValueStoreContext } from '@/lib/execution/payloads/store'
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
export {
isLargeArrayManifest,
LARGE_ARRAY_MANIFEST_MARKER,
LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES,
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
type LargeArrayManifestChunk,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
export const LARGE_ARRAY_MANIFEST_CHUNK_TARGET_BYTES = Math.floor(
MAX_INLINE_MATERIALIZATION_BYTES / 2
)
const LARGE_ARRAY_MANIFEST_JSON_SERIALIZATION_ERROR =
'Large array manifest chunks must be JSON-serializable.'
export interface LargeArrayManifestReadOptions extends LargeValueStoreContext {
maxBytes?: number
}
export interface LargeArrayManifestWriteOptions extends LargeValueStoreContext {
chunkTargetBytes?: number
}
function measureJson(value: unknown): { json: string; size: number } {
let json: string | undefined
try {
json = JSON.stringify(value)
} catch {
throw new Error(LARGE_ARRAY_MANIFEST_JSON_SERIALIZATION_ERROR)
}
if (json === undefined) {
throw new Error(LARGE_ARRAY_MANIFEST_JSON_SERIALIZATION_ERROR)
}
return { json, size: Buffer.byteLength(json, 'utf8') }
}
function assertArray(value: unknown): asserts value is unknown[] {
if (!Array.isArray(value)) {
throw new Error('Large array manifest chunks must materialize to arrays.')
}
}
function assertChunkCount(chunk: unknown[], expectedCount: number): void {
if (chunk.length !== expectedCount) {
throw new Error('Large array manifest chunk count does not match materialized data.')
}
}
function getPreview(items: unknown[]): unknown[] {
const preview: unknown[] = []
for (const item of items.slice(0, 3)) {
const candidate = [...preview, item]
try {
if (measureJson(candidate).size > LARGE_ARRAY_MANIFEST_PREVIEW_MAX_BYTES) {
break
}
} catch {
break
}
preview.push(item)
}
return preview
}
function measureArrayElementJsonSize(item: unknown): number {
const measured = measureJson([item])
return Math.max(0, measured.size - 2)
}
async function storeArrayChunk(
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifestChunk> {
const measured = measureJson(items)
const ref = await storeLargeValue(items, measured.json, measured.size, {
...context,
requireDurable: true,
})
return { ref, count: items.length, byteSize: measured.size }
}
function chunkArrayItems(items: unknown[], targetBytes: number): unknown[][] {
const chunks: unknown[][] = []
let current: unknown[] = []
let currentBytes = 2
for (const item of items) {
const itemBytes = measureArrayElementJsonSize(item)
const separatorBytes = current.length > 0 ? 1 : 0
if (current.length > 0 && currentBytes + separatorBytes + itemBytes > targetBytes) {
chunks.push(current)
current = []
currentBytes = 2
}
current.push(item)
currentBytes += (current.length > 1 ? 1 : 0) + itemBytes
}
if (current.length > 0) {
chunks.push(current)
}
return chunks
}
async function storeArrayChunks(
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifestChunk[]> {
const targetBytes = Math.max(
2,
Math.min(
context.chunkTargetBytes ?? LARGE_ARRAY_MANIFEST_CHUNK_TARGET_BYTES,
MAX_INLINE_MATERIALIZATION_BYTES
)
)
const chunks = chunkArrayItems(items, targetBytes)
const storedChunks: LargeArrayManifestChunk[] = []
for (const chunk of chunks) {
storedChunks.push(await storeArrayChunk(chunk, context))
}
return storedChunks
}
function assertLargeArrayManifest(value: LargeArrayManifest): void {
if (!isLargeArrayManifest(value)) {
throw new Error('Invalid large array manifest.')
}
}
export async function createLargeArrayManifest(
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifest> {
if (items.length === 0) {
return {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 0,
chunkCount: 0,
byteSize: 0,
chunks: [],
preview: [],
}
}
const chunks = await storeArrayChunks(items, context)
const byteSize = chunks.reduce((sum, chunk) => sum + chunk.byteSize, 0)
return {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: items.length,
chunkCount: chunks.length,
byteSize,
chunks,
preview: getPreview(items),
}
}
export async function appendLargeArrayManifest(
manifest: LargeArrayManifest,
items: unknown[],
context: LargeArrayManifestWriteOptions
): Promise<LargeArrayManifest> {
if (items.length === 0) {
return manifest
}
const chunks = await storeArrayChunks(items, context)
const byteSize = chunks.reduce((sum, chunk) => sum + chunk.byteSize, 0)
return {
...manifest,
totalCount: manifest.totalCount + items.length,
chunkCount: manifest.chunkCount + chunks.length,
byteSize: manifest.byteSize + byteSize,
chunks: [...manifest.chunks, ...chunks],
preview: manifest.preview.length > 0 ? manifest.preview : getPreview(items),
}
}
export async function readLargeArrayManifestSlice(
manifest: LargeArrayManifest,
start: number,
limit: number,
context: LargeArrayManifestReadOptions
): Promise<unknown[]> {
assertLargeArrayManifest(manifest)
const normalizedStart = Math.max(0, Math.floor(start))
const normalizedLimit = Math.max(0, Math.floor(limit))
if (normalizedLimit === 0 || normalizedStart >= manifest.totalCount) {
return []
}
const end = Math.min(manifest.totalCount, normalizedStart + normalizedLimit)
const results: unknown[] = []
let cursor = 0
for (const chunkEntry of manifest.chunks) {
const chunkStart = cursor
const chunkEnd = cursor + chunkEntry.count
if (chunkEnd <= normalizedStart || chunkStart >= end) {
cursor = chunkEnd
continue
}
const chunk = await materializeLargeValueRef(chunkEntry.ref, context)
if (chunk === undefined) {
throw new Error('Large array manifest chunk is unavailable.')
}
assertArray(chunk)
assertChunkCount(chunk, chunkEntry.count)
const from = Math.max(0, normalizedStart - chunkStart)
const to = Math.min(chunk.length, end - chunkStart)
results.push(...chunk.slice(from, to))
cursor = chunkEnd
if (cursor >= end) {
break
}
}
return results
}
export async function materializeLargeArrayManifest(
manifest: LargeArrayManifest,
context: LargeArrayManifestReadOptions
): Promise<unknown[]> {
assertLargeArrayManifest(manifest)
assertInlineMaterializationSize(manifest.byteSize, context.maxBytes)
return readLargeArrayManifestSlice(manifest, 0, manifest.totalCount, context)
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
LARGE_ARRAY_MANIFEST_MARKER,
LARGE_ARRAY_MANIFEST_VERSION,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import {
collectLargeValueExecutionIds,
collectLargeValueKeys,
} from '@/lib/execution/payloads/large-execution-value'
import {
LARGE_VALUE_REF_MARKER,
LARGE_VALUE_REF_VERSION,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
function largeValueRef(id: string, executionId: string): LargeValueRef {
return {
[LARGE_VALUE_REF_MARKER]: true,
version: LARGE_VALUE_REF_VERSION,
id,
kind: 'object',
size: 10,
key: `execution/workspace-1/workflow-1/${executionId}/large-value-${id}.json`,
executionId,
}
}
function largeArrayManifest(executionId: string): LargeArrayManifest {
const ref = largeValueRef('lv_MNOPQRSTUVWX', executionId)
return {
[LARGE_ARRAY_MANIFEST_MARKER]: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: ref.size,
chunks: [{ ref, count: 1, byteSize: ref.size }],
preview: [],
}
}
describe('collectLargeValueExecutionIds', () => {
it('collects deduplicated execution IDs from nested refs and manifests', () => {
const executionIds = collectLargeValueExecutionIds({
blockStates: {
upstream: {
output: {
directRef: largeValueRef('lv_ABCDEFGHIJKL', 'execution-a'),
inheritedManifest: largeArrayManifest('execution-b'),
duplicateRef: largeValueRef('lv_NOPQRSTUVWXY', 'execution-a'),
},
},
},
})
expect(executionIds).toEqual(['execution-a', 'execution-b'])
})
it('collects deduplicated storage keys from nested refs and manifests', () => {
const keys = collectLargeValueKeys({
directRef: largeValueRef('lv_ABCDEFGHIJKL', 'execution-a'),
manifest: largeArrayManifest('execution-b'),
})
expect(keys).toEqual([
'execution/workspace-1/workflow-1/execution-a/large-value-lv_ABCDEFGHIJKL.json',
'execution/workspace-1/workflow-1/execution-b/large-value-lv_MNOPQRSTUVWX.json',
])
})
})
@@ -0,0 +1,130 @@
import {
isLargeArrayManifest,
type LargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
export type LargeExecutionValue = LargeValueRef | LargeArrayManifest
/**
* Parses execution values that must survive type coercion as refs.
*/
export function parseLargeExecutionValue(value: unknown): LargeExecutionValue | undefined {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
if (typeof value !== 'string' || !value.trim()) {
return undefined
}
try {
const parsed = JSON.parse(value)
return isLargeValueRef(parsed) || isLargeArrayManifest(parsed) ? parsed : undefined
} catch {
return undefined
}
}
/**
* Finds execution IDs referenced by large values embedded in persisted execution state.
*/
export function collectLargeValueExecutionIds(value: unknown): string[] {
const executionIds = new Set<string>()
collectLargeValueExecutionIdsInto(value, executionIds, new WeakSet<object>())
return Array.from(executionIds)
}
export function collectLargeValueKeys(value: unknown): string[] {
const keys = new Set<string>()
collectLargeValueKeysInto(value, keys, new WeakSet<object>())
return Array.from(keys)
}
function collectLargeValueExecutionIdsInto(
value: unknown,
executionIds: Set<string>,
seen: WeakSet<object>
): void {
if (!value || typeof value !== 'object') {
return
}
if (seen.has(value)) {
return
}
seen.add(value)
if (isLargeValueRef(value)) {
addExecutionId(value, executionIds)
collectLargeValueExecutionIdsInto(value.preview, executionIds, seen)
return
}
if (isLargeArrayManifest(value)) {
for (const chunk of value.chunks) {
addExecutionId(chunk.ref, executionIds)
}
collectLargeValueExecutionIdsInto(value.preview, executionIds, seen)
return
}
if (Array.isArray(value)) {
for (const item of value) {
collectLargeValueExecutionIdsInto(item, executionIds, seen)
}
return
}
for (const item of Object.values(value)) {
collectLargeValueExecutionIdsInto(item, executionIds, seen)
}
}
function addExecutionId(ref: LargeValueRef, executionIds: Set<string>): void {
if (ref.executionId) {
executionIds.add(ref.executionId)
}
}
function collectLargeValueKeysInto(value: unknown, keys: Set<string>, seen: WeakSet<object>): void {
if (!value || typeof value !== 'object') {
return
}
if (seen.has(value)) {
return
}
seen.add(value)
if (isLargeValueRef(value)) {
addKey(value, keys)
collectLargeValueKeysInto(value.preview, keys, seen)
return
}
if (isLargeArrayManifest(value)) {
for (const chunk of value.chunks) {
addKey(chunk.ref, keys)
}
collectLargeValueKeysInto(value.preview, keys, seen)
return
}
if (Array.isArray(value)) {
for (const item of value) {
collectLargeValueKeysInto(item, keys, seen)
}
return
}
for (const item of Object.values(value)) {
collectLargeValueKeysInto(item, keys, seen)
}
}
function addKey(ref: LargeValueRef, keys: Set<string>): void {
if (ref.key) {
keys.add(ref.key)
}
}
@@ -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[])
)
)
)
)
)
)
`
}
@@ -0,0 +1,97 @@
export const LARGE_VALUE_REF_MARKER = '__simLargeValueRef'
export const LARGE_VALUE_THRESHOLD_BYTES = 8 * 1024 * 1024
export const LARGE_VALUE_REF_VERSION = 1
export const LARGE_VALUE_KINDS = ['array', 'object', 'string', 'json'] as const
export type LargeValueKind = (typeof LARGE_VALUE_KINDS)[number]
export interface LargeValueRef {
[LARGE_VALUE_REF_MARKER]: true
version: typeof LARGE_VALUE_REF_VERSION
id: string
kind: LargeValueKind
size: number
key?: string
executionId?: string
preview?: unknown
}
const LARGE_VALUE_ID_PATTERN = /^lv_[A-Za-z0-9_-]{12}$/
export function isLargeValueStorageKey(key: string, id: string, executionId?: string): boolean {
if (!key.startsWith('execution/')) return false
if (!key.endsWith(`/large-value-${id}.json`)) return false
if (executionId && !key.includes(`/${executionId}/`)) return false
return true
}
export function isLargeValueRef(value: unknown): value is LargeValueRef {
if (!value || typeof value !== 'object') return false
const candidate = value as Record<string, unknown>
const id = candidate.id
const key = candidate.key
const executionId = candidate.executionId
return (
candidate[LARGE_VALUE_REF_MARKER] === true &&
candidate.version === LARGE_VALUE_REF_VERSION &&
typeof id === 'string' &&
LARGE_VALUE_ID_PATTERN.test(id) &&
typeof candidate.kind === 'string' &&
(LARGE_VALUE_KINDS as readonly string[]).includes(candidate.kind) &&
typeof candidate.size === 'number' &&
Number.isFinite(candidate.size) &&
candidate.size > 0 &&
(executionId === undefined || typeof executionId === 'string') &&
(key === undefined ||
(typeof key === 'string' &&
isLargeValueStorageKey(key, id, executionId as string | undefined)))
)
}
export function containsLargeValueRef(
value: unknown,
seen = new WeakSet<object>()
): LargeValueRef | null {
if (!value || typeof value !== 'object') return null
if (isLargeValueRef(value)) return value
if (seen.has(value)) return null
seen.add(value)
if (Array.isArray(value)) {
for (const item of value) {
const ref = containsLargeValueRef(item, seen)
if (ref) return ref
}
return null
}
for (const entryValue of Object.values(value)) {
const ref = containsLargeValueRef(entryValue, seen)
if (ref) return ref
}
return null
}
export function getLargeValueMaterializationError(ref: LargeValueRef): Error {
return new Error(
`This execution value is too large to inline (${formatLargeValueSize(ref.size)}). Select a nested field or reduce the amount of data passed between blocks.`
)
}
export function formatLargeValueSize(bytes: number): string {
const megabytes = bytes / (1024 * 1024)
return `${megabytes.toFixed(1)} MB`
}
export function assertNoLargeValueRefs(value: unknown): void {
const ref = containsLargeValueRef(value)
if (ref) {
throw getLargeValueMaterializationError(ref)
}
}
@@ -0,0 +1,327 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
getLargeValueMaterializationError,
isLargeValueRef,
isLargeValueStorageKey,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors'
import type { StorageContext } from '@/lib/uploads'
import { bufferToBase64, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import type { UserFile } from '@/executor/types'
const logger = createLogger('ExecutionPayloadMaterialization')
export const MAX_DURABLE_LARGE_VALUE_BYTES = 64 * 1024 * 1024
export const MAX_INLINE_MATERIALIZATION_BYTES = 16 * 1024 * 1024
export const MAX_FUNCTION_FILE_BYTES = 64 * 1024 * 1024
export const MAX_FUNCTION_INLINE_BYTES = 10 * 1024 * 1024
export interface ExecutionMaterializationContext {
workflowId?: string
workspaceId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
requestId?: string
logger?: Logger
}
export interface MaterializeLargeValueOptions extends ExecutionMaterializationContext {
maxBytes?: number
}
export interface ReadUserFileContentOptions extends ExecutionMaterializationContext {
maxBytes?: number
maxSourceBytes?: number
offset?: number
length?: number
chunked?: boolean
encoding: 'base64' | 'text'
}
function getLogger(options: ExecutionMaterializationContext): Logger {
return options.logger ?? logger
}
export function assertDurableLargeValueSize(size: number): void {
if (size > MAX_DURABLE_LARGE_VALUE_BYTES) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: size,
limitBytes: MAX_DURABLE_LARGE_VALUE_BYTES,
})
}
}
export function assertInlineMaterializationSize(size: number, maxBytes?: number): void {
const limit = maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES
if (size > limit) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: size,
limitBytes: limit,
})
}
}
export function isValidLargeValueKey(ref: LargeValueRef): boolean {
return Boolean(ref.key && isLargeValueStorageKey(ref.key, ref.id, ref.executionId))
}
export function assertLargeValueRefAccess(
ref: LargeValueRef,
context: ExecutionMaterializationContext
): void {
if (!context.executionId) {
throw new Error('Large execution value requires an execution context.')
}
const allowedExecutionIds = new Set([
context.executionId,
...(context.largeValueExecutionIds ?? []),
])
const allowedKeys = new Set(context.largeValueKeys ?? [])
const parts = ref.key?.split('/') ?? []
const [, workspaceId, workflowId, executionId] = parts
if (!ref.key) {
if (ref.executionId && !allowedExecutionIds.has(ref.executionId)) {
throw new Error('Large execution value is not available in this execution.')
}
return
}
if (!context.workspaceId || !context.workflowId) {
throw new Error('Large execution value requires workspace and workflow context.')
}
const workflowScopeAllowed =
context.allowLargeValueWorkflowScope &&
context.workspaceId === workspaceId &&
context.workflowId === workflowId
if (context.workspaceId && workspaceId !== context.workspaceId) {
throw new Error('Large execution value is not available in this execution.')
}
if (context.workflowId && workflowId !== context.workflowId) {
throw new Error('Large execution value is not available in this execution.')
}
if (allowedKeys.has(ref.key)) {
return
}
if (ref.executionId && !allowedExecutionIds.has(ref.executionId) && !workflowScopeAllowed) {
throw new Error('Large execution value is not available in this execution.')
}
if (!allowedExecutionIds.has(executionId) && !workflowScopeAllowed) {
throw new Error('Large execution value is not available in this execution.')
}
}
export async function readLargeValueRefFromStorage(
ref: LargeValueRef,
options: MaterializeLargeValueOptions = {}
): Promise<unknown | undefined> {
const log = getLogger(options)
if (!isLargeValueRef(ref) || !ref.key || !isValidLargeValueKey(ref)) {
return undefined
}
assertLargeValueRefAccess(ref, options)
assertInlineMaterializationSize(ref.size, options.maxBytes)
const maxBytes = options.maxBytes ?? MAX_INLINE_MATERIALIZATION_BYTES
try {
const { StorageService } = await import('@/lib/uploads')
const buffer = await StorageService.downloadFile({
key: ref.key,
context: 'execution',
maxBytes,
})
if (buffer.length > maxBytes) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: buffer.length,
limitBytes: maxBytes,
})
}
return JSON.parse(buffer.toString('utf8'))
} catch (error) {
if (isPayloadSizeLimitError(error)) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: error.observedBytes ?? maxBytes + 1,
limitBytes: maxBytes,
})
}
if (error instanceof ExecutionResourceLimitError) {
throw error
}
log.warn('Failed to materialize persisted large execution value', {
id: ref.id,
key: ref.key,
error: toError(error).message,
})
return undefined
}
}
function normalizeRange(buffer: Buffer, options: ReadUserFileContentOptions): Buffer {
const offset = Math.max(0, Math.floor(options.offset ?? 0))
const maxLength = options.maxBytes ?? MAX_FUNCTION_INLINE_BYTES
const requestedLength = options.length === undefined ? maxLength : Math.floor(options.length)
const length = Math.max(0, Math.min(requestedLength, maxLength))
return buffer.subarray(offset, offset + length)
}
function getExecutionKeyParts(key: string):
| {
workspaceId: string
workflowId: string
executionId: string
}
| undefined {
const parts = key.split('/')
if (parts[0] !== 'execution' || parts.length < 5) {
return undefined
}
return {
workspaceId: parts[1],
workflowId: parts[2],
executionId: parts[3],
}
}
function assertExecutionFileScope(key: string, options: ExecutionMaterializationContext): void {
const parts = getExecutionKeyParts(key)
if (!parts) {
throw new Error('File is not available in this execution.')
}
const allowedExecutionIds = new Set([
options.executionId,
...(options.largeValueExecutionIds ?? []),
])
const allowedFileKeys = new Set(options.fileKeys ?? [])
const workflowScopeAllowed =
options.allowLargeValueWorkflowScope &&
options.workspaceId === parts.workspaceId &&
options.workflowId === parts.workflowId
if (options.workspaceId && parts.workspaceId !== options.workspaceId) {
throw new Error('File is not available in this execution.')
}
if (options.workflowId && parts.workflowId !== options.workflowId) {
throw new Error('File is not available in this execution.')
}
if (allowedFileKeys.has(key)) {
return
}
if (
!options.executionId ||
(!allowedExecutionIds.has(parts.executionId) && !workflowScopeAllowed)
) {
throw new Error('File is not available in this execution.')
}
}
function getVerifiedStorageContext(file: UserFile): StorageContext {
if (!file.key) {
throw new Error('File content requires a storage key.')
}
const inferredContext = inferContextFromKey(file.key)
if (file.context && file.context !== inferredContext) {
throw new Error('File context does not match its storage key.')
}
return inferredContext
}
export async function assertUserFileContentAccess(
file: UserFile,
options: ExecutionMaterializationContext
): Promise<void> {
const context = getVerifiedStorageContext(file)
if (context === 'execution') {
assertExecutionFileScope(file.key, options)
}
if (!options.userId) {
throw new Error('File access requires an authenticated user.')
}
const { verifyFileAccess } = await import('@/app/api/files/authorization')
const hasAccess = await verifyFileAccess(file.key, options.userId, undefined, context, false)
if (!hasAccess) {
throw new Error('File is not available in this execution.')
}
}
export async function readUserFileContent(
file: unknown,
options: ReadUserFileContentOptions
): Promise<string> {
if (!isUserFileWithMetadata(file)) {
throw new Error('Expected a file object with metadata.')
}
await assertUserFileContentAccess(file, options)
const maxSourceBytes = options.maxSourceBytes ?? MAX_FUNCTION_FILE_BYTES
if (Number.isFinite(file.size) && file.size > maxSourceBytes) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: file.size,
limitBytes: maxSourceBytes,
})
}
let buffer: Buffer | null = null
const log = getLogger(options)
const requestId = options.requestId ?? 'unknown'
try {
buffer = await downloadFileFromStorage(file, requestId, log, { maxBytes: maxSourceBytes })
} catch (error) {
if (isPayloadSizeLimitError(error)) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: error.observedBytes ?? maxSourceBytes + 1,
limitBytes: maxSourceBytes,
})
}
throw error
}
if (!buffer) {
throw new Error(`File content for ${file.name} is unavailable.`)
}
if (buffer.length > maxSourceBytes) {
throw new ExecutionResourceLimitError({
resource: 'execution_payload_bytes',
attemptedBytes: buffer.length,
limitBytes: maxSourceBytes,
})
}
const shouldSlice =
options.chunked || options.offset !== undefined || options.length !== undefined
const selected = shouldSlice ? normalizeRange(buffer, options) : buffer
assertInlineMaterializationSize(selected.length, options.maxBytes ?? MAX_FUNCTION_INLINE_BYTES)
return options.encoding === 'base64' ? bufferToBase64(selected) : selected.toString('utf8')
}
export function unavailableLargeValueError(ref: LargeValueRef): Error {
return getLargeValueMaterializationError(ref)
}
@@ -0,0 +1,285 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
isLargeArrayManifest,
LARGE_ARRAY_MANIFEST_VERSION,
readLargeArrayManifestSlice,
} from '@/lib/execution/payloads/large-array-manifest'
import {
getLargeValueMaterializationError,
isLargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import { compactExecutionPayload, compactSubflowResults } from '@/lib/execution/payloads/serializer'
import type { UserFile } from '@/executor/types'
const TEST_EXECUTION_CONTEXT = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
}
describe('compactExecutionPayload', () => {
it('keeps small JSON payloads inline', async () => {
const value = { result: { id: 'event-1', text: 'hello' } }
await expect(compactExecutionPayload(value, { thresholdBytes: 1024 })).resolves.toEqual(value)
})
it('strips UserFile base64 by default while preserving metadata', async () => {
const file: UserFile = {
id: 'file-1',
name: 'large.txt',
url: 'https://example.com/file',
size: 11 * 1024 * 1024,
type: 'text/plain',
key: 'execution/workflow/execution/large.txt',
context: 'execution',
base64: 'Zm9v',
}
const compacted = await compactExecutionPayload(
{ event: { files: [file] } },
{ thresholdBytes: 1024 }
)
expect(compacted).toEqual({
event: {
files: [
{
id: 'file-1',
name: 'large.txt',
url: 'https://example.com/file',
size: 11 * 1024 * 1024,
type: 'text/plain',
key: 'execution/workflow/execution/large.txt',
context: 'execution',
},
],
},
})
})
it('stores oversized arrays as manifests and allows bounded slice reads', async () => {
const results = Array.from({ length: 100 }, (_, index) => [{ event: { id: `event-${index}` } }])
const compacted = await compactExecutionPayload(
{ results },
{ thresholdBytes: 1024, ...TEST_EXECUTION_CONTEXT }
)
expect(isLargeArrayManifest(compacted.results)).toBe(true)
expect(compacted.results.totalCount).toBe(100)
await expect(
readLargeArrayManifestSlice(compacted.results, 1, 1, TEST_EXECUTION_CONTEXT)
).resolves.toEqual([[{ event: { id: 'event-1' } }]])
})
it('keeps oversized strings and objects as large value refs', async () => {
const compacted = await compactExecutionPayload(
{
text: 'x'.repeat(2048),
metadata: Object.fromEntries(
Array.from({ length: 100 }, (_, index) => [`key-${index}`, `value-${index}`])
),
},
{ thresholdBytes: 1024, ...TEST_EXECUTION_CONTEXT }
)
expect(isLargeValueRef(compacted.text)).toBe(true)
expect(isLargeValueRef(compacted.metadata)).toBe(true)
})
it('rejects oversized values before preserving or spilling them when requested', async () => {
await expect(
compactExecutionPayload(
{ root: Object.fromEntries(Array.from({ length: 100 }, (_, index) => [`k${index}`, 'x'])) },
{
thresholdBytes: 256,
preserveRoot: true,
rejectLargeValues: true,
rejectLargeValueLabel: 'Workflow execution response',
...TEST_EXECUTION_CONTEXT,
}
)
).rejects.toMatchObject({
name: 'PayloadSizeLimitError',
label: 'Workflow execution response',
})
})
it('does not double-spill existing refs', async () => {
const compacted = await compactExecutionPayload(
{ results: [[{ payload: 'x'.repeat(2048) }]] },
{ thresholdBytes: 256 }
)
const compactedAgain = await compactExecutionPayload(compacted, { thresholdBytes: 256 })
expect(compactedAgain).toEqual(compacted)
})
it('bounds user-supplied manifest-shaped metadata during compaction', async () => {
const forgedManifest = {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 2,
chunkCount: 2,
byteSize: 2,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_MNOPQRSTUVWX',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
],
preview: [],
}
expect(isLargeArrayManifest(forgedManifest)).toBe(true)
const compacted = await compactExecutionPayload(forgedManifest, {
thresholdBytes: 128,
preserveRoot: true,
...TEST_EXECUTION_CONTEXT,
})
expect(isLargeValueRef(compacted)).toBe(true)
})
it('bounds oversized manifest preview metadata during compaction', async () => {
const forgedManifest = {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 1,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
],
preview: [{ payload: 'x'.repeat(20 * 1024) }],
}
expect(isLargeArrayManifest(forgedManifest)).toBe(true)
const compacted = await compactExecutionPayload(forgedManifest, {
thresholdBytes: 128,
preserveRoot: true,
...TEST_EXECUTION_CONTEXT,
})
expect(isLargeValueRef(compacted)).toBe(true)
})
it('does not re-wrap manifests when forcing oversized subflow result entries', async () => {
const manifest = {
__simLargeArrayManifest: true,
version: LARGE_ARRAY_MANIFEST_VERSION,
kind: 'array',
totalCount: 1,
chunkCount: 1,
byteSize: 1,
chunks: [
{
ref: {
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'array',
size: 1,
executionId: TEST_EXECUTION_CONTEXT.executionId,
},
count: 1,
byteSize: 1,
},
],
preview: [],
}
const thresholdBytes = Buffer.byteLength(JSON.stringify(manifest), 'utf8') + 8
const compacted = await compactSubflowResults([manifest, manifest], {
thresholdBytes,
...TEST_EXECUTION_CONTEXT,
})
expect(compacted).toEqual([manifest, manifest])
expect(compacted.every(isLargeArrayManifest)).toBe(true)
})
it('rejects durable compaction when storage context is incomplete', async () => {
await expect(
compactExecutionPayload(
{ payload: 'x'.repeat(2048) },
{ thresholdBytes: 256, requireDurable: true }
)
).rejects.toThrow('Cannot persist large execution value')
})
it('does not treat loosely marker-shaped user data as a large-value ref', () => {
expect(
isLargeValueRef({
__simLargeValueRef: true,
id: 'user-supplied',
})
).toBe(false)
})
it('rejects ref-shaped user data with non-execution storage keys', () => {
expect(
isLargeValueRef({
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 1024,
key: 'https://example.com/large-value-lv_ABCDEFGHIJKL.json',
})
).toBe(false)
})
it('omits opaque ref IDs from user-facing materialization errors', () => {
const error = getLargeValueMaterializationError({
__simLargeValueRef: true,
version: 1,
id: 'lv_CQcekP8gSJI5',
kind: 'string',
size: 23_259_101,
})
expect(error.message).toContain('This execution value is too large to inline (22.2 MB)')
expect(error.message).not.toContain('lv_CQcekP8gSJI5')
})
})
@@ -0,0 +1,281 @@
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
createLargeArrayManifest,
isLargeArrayManifest,
} from '@/lib/execution/payloads/large-array-manifest'
import {
isLargeValueRef,
LARGE_VALUE_THRESHOLD_BYTES,
} from '@/lib/execution/payloads/large-value-ref'
import { type LargeValueStoreContext, storeLargeValue } from '@/lib/execution/payloads/store'
import type { BlockLog } from '@/executor/types'
export interface CompactExecutionPayloadOptions extends LargeValueStoreContext {
thresholdBytes?: number
preserveUserFileBase64?: boolean
preserveRoot?: boolean
rejectLargeValues?: boolean
rejectLargeValueLabel?: string
}
interface CompactState {
seen: WeakSet<object>
}
function getJsonAndSize(value: unknown): { json: string; size: number } | null {
try {
const json = JSON.stringify(value)
if (json === undefined) {
return null
}
return {
json,
size: Buffer.byteLength(json, 'utf8'),
}
} catch {
return null
}
}
function stripUserFileBase64<T extends { base64?: unknown }>(value: T): Omit<T, 'base64'> {
const { base64: _base64, ...rest } = value
return rest
}
function canPersistDurably(options: CompactExecutionPayloadOptions): boolean {
return Boolean(options.workspaceId && options.workflowId && options.executionId)
}
function largeValueLimitError(
options: CompactExecutionPayloadOptions,
observedBytes: number
): PayloadSizeLimitError {
return new PayloadSizeLimitError({
label: options.rejectLargeValueLabel ?? 'Large execution value',
maxBytes: options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES,
observedBytes,
})
}
function assertRejectSize(observedBytes: number, options: CompactExecutionPayloadOptions): void {
if (!options.rejectLargeValues) return
if (observedBytes > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
throw largeValueLimitError(options, observedBytes)
}
}
async function compactValue(
value: unknown,
options: CompactExecutionPayloadOptions,
state: CompactState,
depth = 0
): Promise<unknown> {
if (!value || typeof value !== 'object') {
const measured = getJsonAndSize(value)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
if (options.rejectLargeValues) {
throw largeValueLimitError(options, measured.size)
}
return options.preserveRoot && depth === 0
? value
: storeLargeValue(value, measured.json, measured.size, options)
}
return value
}
if (isLargeValueRef(value)) {
return value
}
if (isLargeArrayManifest(value)) {
const measured = getJsonAndSize(value)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
if (options.rejectLargeValues) {
throw largeValueLimitError(options, measured.size)
}
return storeLargeValue(value, measured.json, measured.size, options)
}
return value
}
if (isUserFileWithMetadata(value) && !options.preserveUserFileBase64) {
return stripUserFileBase64(value)
}
if (state.seen.has(value)) {
return value
}
state.seen.add(value)
const compacted = await compactEntries(value, options, state, depth)
const measured = getJsonAndSize(compacted)
if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
if (options.rejectLargeValues) {
throw largeValueLimitError(options, measured.size)
}
if (Array.isArray(compacted) && (canPersistDurably(options) || options.requireDurable)) {
return createLargeArrayManifest(compacted, { ...options, requireDurable: true })
}
if (options.preserveRoot && depth === 0) {
return compacted
}
return storeLargeValue(compacted, measured.json, measured.size, options)
}
return compacted
}
async function compactEntries(
value: object,
options: CompactExecutionPayloadOptions,
state: CompactState,
depth: number
): Promise<unknown> {
if (options.rejectLargeValues) {
return compactEntriesWithEarlyReject(value, options, state, depth)
}
if (Array.isArray(value)) {
return Promise.all(value.map((item) => compactValue(item, options, state, depth + 1)))
}
return Object.fromEntries(
await Promise.all(
Object.entries(value).map(async ([key, entryValue]) => [
key,
key === 'finalBlockLogs' && Array.isArray(entryValue)
? await compactBlockLogs(entryValue as BlockLog[], options)
: await compactValue(entryValue, options, state, depth + 1),
])
)
)
}
async function compactEntriesWithEarlyReject(
value: object,
options: CompactExecutionPayloadOptions,
state: CompactState,
depth: number
): Promise<unknown> {
if (Array.isArray(value)) {
const compacted: unknown[] = []
let estimatedBytes = 2
for (const item of value) {
const compactedItem = await compactValue(item, options, state, depth + 1)
compacted.push(compactedItem)
const measured = getJsonAndSize(compactedItem)
estimatedBytes += (compacted.length > 1 ? 1 : 0) + (measured?.size ?? 4)
assertRejectSize(estimatedBytes, options)
}
return compacted
}
const compacted: Record<string, unknown> = {}
let estimatedBytes = 2
let serializedPropertyCount = 0
for (const [key, entryValue] of Object.entries(value)) {
const compactedEntry =
key === 'finalBlockLogs' && Array.isArray(entryValue)
? await compactBlockLogs(entryValue as BlockLog[], options)
: await compactValue(entryValue, options, state, depth + 1)
compacted[key] = compactedEntry
const measured = getJsonAndSize(compactedEntry)
if (measured) {
const keyJson = JSON.stringify(key)
estimatedBytes +=
(serializedPropertyCount > 0 ? 1 : 0) +
Buffer.byteLength(keyJson, 'utf8') +
1 +
measured.size
serializedPropertyCount += 1
assertRejectSize(estimatedBytes, options)
}
}
return compacted
}
async function forceStoreValue(
value: unknown,
options: CompactExecutionPayloadOptions
): Promise<unknown> {
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
const measured = getJsonAndSize(value)
if (!measured) {
return value
}
return storeLargeValue(value, measured.json, measured.size, options)
}
export async function compactExecutionPayload<T>(
value: T,
options: CompactExecutionPayloadOptions = {}
): Promise<T> {
return (await compactValue(value, options, { seen: new WeakSet<object>() })) as T
}
export async function compactWorkflowVariableValue<T>(
value: T,
options: CompactExecutionPayloadOptions = {}
): Promise<T> {
return compactExecutionPayload(value, { ...options, requireDurable: true })
}
/**
* Compacts subflow result aggregates while preserving indexable `results`.
*/
export async function compactSubflowResults<T>(
results: T[],
options: CompactExecutionPayloadOptions = {}
): Promise<T[]> {
const entryOptions = { ...options, preserveRoot: false }
let compactedResults = (await Promise.all(
results.map((result) => compactExecutionPayload(result, entryOptions))
)) as T[]
const aggregate = getJsonAndSize({ results: compactedResults })
if (aggregate && aggregate.size <= (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) {
return compactedResults
}
compactedResults = (await Promise.all(
compactedResults.map((result) => forceStoreValue(result, options))
)) as T[]
return compactedResults
}
export async function compactBlockLogs(
logs: BlockLog[] | undefined,
options: CompactExecutionPayloadOptions = {}
): Promise<BlockLog[] | undefined> {
if (!logs) {
return logs
}
return Promise.all(
logs.map(async (log) => {
const compactedLog = { ...log }
if ('input' in compactedLog) {
compactedLog.input = await compactExecutionPayload(compactedLog.input, options)
}
if ('output' in compactedLog) {
compactedLog.output = await compactExecutionPayload(compactedLog.output, options)
}
if ('childTraceSpans' in compactedLog) {
compactedLog.childTraceSpans = await compactExecutionPayload(
compactedLog.childTraceSpans,
options
)
}
return compactedLog
})
)
}
@@ -0,0 +1,765 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import {
cacheLargeValue,
clearLargeValueCacheForTests,
materializeLargeValueRefSync,
} from '@/lib/execution/payloads/cache'
import {
MAX_DURABLE_LARGE_VALUE_BYTES,
readLargeValueRefFromStorage,
readUserFileContent,
} from '@/lib/execution/payloads/materialization.server'
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
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: {
deleteFiles: mockDeleteFiles,
downloadFile: mockDownloadFile,
uploadFile: mockUploadFile,
},
}))
vi.mock('@/lib/uploads/core/storage-service', () => ({
uploadFile: mockUploadFile,
downloadFile: mockDownloadFile,
}))
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)
})
it('stores oversized JSON in execution object storage and returns a small ref', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
const ref = await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
expect(ref).toMatchObject({
__simLargeValueRef: true,
version: 1,
kind: 'object',
size: Buffer.byteLength(json, 'utf8'),
executionId: 'execution-1',
})
expect(ref.key).toBe(`execution/workspace-1/workflow-1/execution-1/large-value-${ref.id}.json`)
expect(mockUploadFile).toHaveBeenCalledWith(
expect.objectContaining({
contentType: 'application/json',
context: 'execution',
preserveKey: true,
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 () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), { requireDurable: true })
).rejects.toThrow('Cannot persist large execution value')
expect(mockUploadFile).not.toHaveBeenCalled()
})
it('fails durable writes when storage upload fails', async () => {
const value = { payload: 'x'.repeat(2048) }
const json = JSON.stringify(value)
mockUploadFile.mockRejectedValueOnce(new Error('storage down'))
await expect(
storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requireDurable: true,
})
).rejects.toThrow('Failed to persist large execution value: storage down')
})
it('materializes object-storage refs through the server helper', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from(JSON.stringify({ ok: true }), 'utf8'))
await expect(
materializeLargeValueRef(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{
workspaceId: 'workflow-1',
workflowId: 'workflow-2',
executionId: 'execution-1',
}
)
).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 () => {
const size = MAX_DURABLE_LARGE_VALUE_BYTES + 1
await expect(
storeLargeValue('x', JSON.stringify('x'), size, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requireDurable: true,
})
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('bounds explicit server-side materialization', async () => {
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 2048,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{
workspaceId: 'workflow-1',
workflowId: 'workflow-2',
executionId: 'execution-1',
maxBytes: 1024,
}
)
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('does not materialize durable refs without caller execution context', async () => {
await expect(
materializeLargeValueRef({
__simLargeValueRef: true,
version: 1,
id: 'lv_NOCTXVALUE12',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_NOCTXVALUE12.json',
executionId: 'execution-1',
})
).resolves.toBeUndefined()
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('checks caller execution context before returning cached large values', async () => {
const value = { payload: 'cached' }
const json = JSON.stringify(value)
const ref = await storeLargeValue(value, json, Buffer.byteLength(json, 'utf8'), {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
requireDurable: true,
})
await expect(
materializeLargeValueRef(ref, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'other-execution',
userId: 'user-1',
})
).rejects.toThrow('Large execution value is not available in this execution.')
})
it('rejects durable refs whose key does not match caller execution context', async () => {
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{ workspaceId: 'workflow-1', workflowId: 'workflow-2', executionId: 'other-execution' }
)
).rejects.toThrow('Large execution value is not available in this execution.')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('allows prior-execution durable refs only when workflow-scoped reads are explicitly enabled', async () => {
mockDownloadFile.mockResolvedValueOnce(Buffer.from(JSON.stringify({ ok: true }), 'utf8'))
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workspace-1/workflow-1/source-execution/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'source-execution',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'resume-execution',
allowLargeValueWorkflowScope: true,
}
)
).resolves.toEqual({ ok: true })
})
it('does not materialize forged keyless refs from another cached execution', () => {
cacheLargeValue('lv_FORGEDCACHE1', { secret: true }, 16, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'source-execution',
})
const forged = {
__simLargeValueRef: true,
version: 1,
id: 'lv_FORGEDCACHE1',
kind: 'object',
size: 16,
executionId: 'other-execution',
} as const
expect(
materializeLargeValueRefSync(forged, {
workspaceId: 'workspace-2',
workflowId: 'workflow-2',
executionId: 'other-execution',
})
).toBeUndefined()
})
it('does not evict unrecoverable in-memory refs for recoverable cache entries', () => {
const scope = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const unrecoverableId = 'lv_UNRECOVER001'
const unrecoverableRef = {
__simLargeValueRef: true,
version: 1,
id: unrecoverableId,
kind: 'object',
size: 200 * 1024 * 1024,
executionId: scope.executionId,
} as const
expect(cacheLargeValue(unrecoverableId, { retained: true }, unrecoverableRef.size, scope)).toBe(
true
)
expect(
cacheLargeValue('lv_RECOVER00001', { recoverable: true }, 70 * 1024 * 1024, scope, {
recoverable: true,
})
).toBe(false)
expect(materializeLargeValueRefSync(unrecoverableRef, scope)).toEqual({ retained: true })
})
it('materializes keyless cached refs through the async helper', async () => {
const scope = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_KEYLESSCACHE',
kind: 'object',
size: 32,
executionId: scope.executionId,
} as const
cacheLargeValue(ref.id, { retained: true }, ref.size, scope)
await expect(materializeLargeValueRef(ref, scope)).resolves.toEqual({ retained: true })
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',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_CACHEDMAXBYTE',
kind: 'object',
size: 2048,
executionId: scope.executionId,
} as const
cacheLargeValue(ref.id, { retained: true }, ref.size, scope)
await expect(materializeLargeValueRef(ref, { ...scope, maxBytes: 1024 })).rejects.toMatchObject(
{
code: EXECUTION_RESOURCE_LIMIT_CODE,
}
)
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('rejects durable refs when caller omits workspace and workflow context', async () => {
await expect(
readLargeValueRefFromStorage(
{
__simLargeValueRef: true,
version: 1,
id: 'lv_ABCDEFGHIJKL',
kind: 'object',
size: 11,
key: 'execution/workflow-1/workflow-2/execution-1/large-value-lv_ABCDEFGHIJKL.json',
executionId: 'execution-1',
},
{ executionId: 'execution-1' }
)
).rejects.toThrow('Large execution value requires workspace and workflow context.')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('rejects execution files with forged public contexts before storage download', async () => {
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'secret.txt',
url: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/secret.txt',
key: 'execution/workspace-1/workflow-1/execution-1/secret.txt',
context: 'profile-pictures',
size: 32,
type: 'text/plain',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
encoding: 'text',
}
)
).rejects.toThrow('File context does not match its storage key.')
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('rejects URL-only file objects instead of reading internal URLs directly', async () => {
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'secret.txt',
url: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/secret.txt?context=execution',
key: '',
size: 32,
type: 'text/plain',
},
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
userId: 'user-1',
encoding: 'text',
}
)
).rejects.toThrow('File content requires a storage key.')
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('throws instead of truncating non-chunked file reads over the inline cap', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello world', 'utf8'))
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 11,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxBytes: 5,
}
)
).rejects.toMatchObject({ code: EXECUTION_RESOURCE_LIMIT_CODE })
})
it('passes source byte limits into execution file storage downloads', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 5,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxSourceBytes: 6,
}
)
).resolves.toBe('hello')
expect(mockDownloadFile).toHaveBeenCalledWith(
expect.objectContaining({
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
maxBytes: 6,
})
)
})
it('converts storage byte-limit failures into execution resource-limit errors', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'storage file download',
maxBytes: 6,
observedBytes: 7,
})
)
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 5,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxSourceBytes: 6,
}
)
).rejects.toMatchObject({
code: EXECUTION_RESOURCE_LIMIT_CODE,
attemptedBytes: 7,
limitBytes: 6,
})
})
it('allows explicit chunked file reads to slice within the inline cap', async () => {
const workspaceId = '11111111-1111-4111-8111-111111111111'
const workflowId = '22222222-2222-4222-8222-222222222222'
const executionId = '33333333-3333-4333-8333-333333333333'
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello world', 'utf8'))
await expect(
readUserFileContent(
{
id: 'file_1',
name: 'hello.txt',
url: `/api/files/serve/execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
key: `execution/${workspaceId}/${workflowId}/${executionId}/hello.txt`,
context: 'execution',
size: 11,
type: 'text/plain',
},
{
workspaceId,
workflowId,
executionId,
userId: 'user-1',
encoding: 'text',
maxBytes: 5,
chunked: true,
}
)
).resolves.toBe('hello')
})
})
+274
View File
@@ -0,0 +1,274 @@
import { createLogger } from '@sim/logger'
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,
type LargeValueRef,
} from '@/lib/execution/payloads/large-value-ref'
import {
assertDurableLargeValueSize,
assertInlineMaterializationSize,
assertLargeValueRefAccess,
isValidLargeValueKey,
readLargeValueRefFromStorage,
} from '@/lib/execution/payloads/materialization.server'
import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
const logger = createLogger('LargeExecutionPayloadStore')
export interface LargeValueStoreContext {
workspaceId?: string
workflowId?: string
executionId?: string
largeValueExecutionIds?: string[]
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
requireDurable?: boolean
maxBytes?: number
/**
* When false, materialization does not register an execution_log reference for
* the key. Read-only consumers (e.g. viewing/exporting a completed log) set
* this: the value is already owned + referenced by its own execution, so
* re-registering on every read is wasteful and a needless failure point.
*/
trackReference?: boolean
}
function getKind(value: unknown): LargeValueKind {
if (typeof value === 'string') return 'string'
if (Array.isArray(value)) return 'array'
if (value && typeof value === 'object') return 'object'
return 'json'
}
function getPreview(value: unknown): unknown {
if (typeof value === 'string') {
return truncate(value, 256)
}
if (Array.isArray(value)) {
return { length: value.length }
}
if (value && typeof value === 'object') {
return { keys: Object.keys(value).slice(0, 20) }
}
return value
}
async function persistValue(
id: string,
json: string,
context: LargeValueStoreContext
): Promise<string | undefined> {
const { workspaceId, workflowId, executionId, userId } = context
if (!workspaceId || !workflowId || !executionId) {
if (context.requireDurable) {
throw new Error(
'Cannot persist large execution value without workspace, workflow, and execution IDs'
)
}
return undefined
}
const key = generateExecutionFileKey(
{ workspaceId, workflowId, executionId },
`large-value-${id}.json`
)
try {
const { StorageService } = await import('@/lib/uploads')
const fileInfo = await StorageService.uploadFile({
file: Buffer.from(json, 'utf8'),
fileName: key,
contentType: 'application/json',
context: 'execution',
preserveKey: true,
customKey: key,
metadata: {
originalName: `large-value-${id}.json`,
uploadedAt: new Date().toISOString(),
purpose: 'execution-large-value',
workspaceId,
...(userId ? { userId } : {}),
},
})
return fileInfo.key
} catch (error) {
if (context.requireDurable) {
throw new Error(`Failed to persist large execution value: ${toError(error).message}`)
}
logger.warn('Failed to persist large execution value, keeping in memory only', {
id,
error: toError(error).message,
})
return undefined
}
}
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,
size: number,
context: LargeValueStoreContext
): Promise<LargeValueRef> {
assertDurableLargeValueSize(size)
const referencedKeys = collectLargeValueKeys(value)
const id = `lv_${generateShortId(12)}`
let key = await persistValue(id, json, context)
if (key) {
// Only clean up the uploaded object when registration definitively did NOT
// record ownership (returns false). If registration THROWS, the metadata
// state is uncertain (a row may have partially committed), so we propagate
// without deleting — deleting could orphan a metadata row pointing at a
// now-missing object.
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')
}
return {
__simLargeValueRef: true,
version: LARGE_VALUE_REF_VERSION,
id,
kind: getKind(value),
size,
key,
executionId: context.executionId,
preview: getPreview(value),
}
}
export async function materializeLargeValueRef(
ref: LargeValueRef,
context?: LargeValueStoreContext
): Promise<unknown> {
if (!context?.executionId) {
return undefined
}
assertLargeValueRefAccess(ref, context)
assertInlineMaterializationSize(ref.size, context.maxBytes)
if (!ref.key || !isValidLargeValueKey(ref)) {
return materializeLargeValueRefSync(ref, context)
}
if (context.trackReference !== false) {
const { addLargeValueReference } = await import('@/lib/execution/payloads/large-value-metadata')
// Reference tracking is GC-critical: if it fails, fail the read rather than
// return a value whose reference was never recorded (it could later be
// garbage-collected out from under a live consumer). Read-only consumers
// that don't need a reference set trackReference: false to skip this.
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,
executionId: context.executionId,
largeValueExecutionIds: context.largeValueExecutionIds,
largeValueKeys: context.largeValueKeys,
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
userId: context.userId,
maxBytes: context.maxBytes ?? ref.size,
})
if (value === undefined) {
return undefined
}
cacheLargeValue(
ref.id,
value,
ref.size,
{
...context,
executionId: ref.executionId ?? context.executionId,
},
{ recoverable: true }
)
return value
} catch (error) {
logger.warn('Failed to materialize persisted large execution value', {
id: ref.id,
key: ref.key,
error,
})
return undefined
}
}