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
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:
@@ -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))
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user