chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildNextCallChain,
|
||||
MAX_CALL_CHAIN_DEPTH,
|
||||
parseCallChain,
|
||||
SIM_VIA_HEADER,
|
||||
serializeCallChain,
|
||||
validateCallChain,
|
||||
} from '@/lib/execution/call-chain'
|
||||
|
||||
describe('call-chain', () => {
|
||||
describe('SIM_VIA_HEADER', () => {
|
||||
it('has the expected header name', () => {
|
||||
expect(SIM_VIA_HEADER).toBe('X-Sim-Via')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MAX_CALL_CHAIN_DEPTH', () => {
|
||||
it('equals 25', () => {
|
||||
expect(MAX_CALL_CHAIN_DEPTH).toBe(25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCallChain', () => {
|
||||
it('returns empty array for null', () => {
|
||||
expect(parseCallChain(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
expect(parseCallChain(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(parseCallChain('')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for whitespace-only string', () => {
|
||||
expect(parseCallChain(' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses a single workflow ID', () => {
|
||||
expect(parseCallChain('wf-abc')).toEqual(['wf-abc'])
|
||||
})
|
||||
|
||||
it('parses multiple comma-separated workflow IDs', () => {
|
||||
expect(parseCallChain('wf-a,wf-b,wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c'])
|
||||
})
|
||||
|
||||
it('trims whitespace around workflow IDs', () => {
|
||||
expect(parseCallChain(' wf-a , wf-b , wf-c ')).toEqual(['wf-a', 'wf-b', 'wf-c'])
|
||||
})
|
||||
|
||||
it('filters out empty segments', () => {
|
||||
expect(parseCallChain('wf-a,,wf-b')).toEqual(['wf-a', 'wf-b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeCallChain', () => {
|
||||
it('serializes an empty array', () => {
|
||||
expect(serializeCallChain([])).toBe('')
|
||||
})
|
||||
|
||||
it('serializes a single ID', () => {
|
||||
expect(serializeCallChain(['wf-a'])).toBe('wf-a')
|
||||
})
|
||||
|
||||
it('serializes multiple IDs with commas', () => {
|
||||
expect(serializeCallChain(['wf-a', 'wf-b', 'wf-c'])).toBe('wf-a,wf-b,wf-c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateCallChain', () => {
|
||||
it('returns null for an empty chain', () => {
|
||||
expect(validateCallChain([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when chain is under max depth', () => {
|
||||
expect(validateCallChain(['wf-a', 'wf-b'])).toBeNull()
|
||||
})
|
||||
|
||||
it('allows legitimate self-recursion', () => {
|
||||
expect(validateCallChain(['wf-a', 'wf-a', 'wf-a'])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns depth error when chain is at max depth', () => {
|
||||
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`)
|
||||
const error = validateCallChain(chain)
|
||||
expect(error).toContain(
|
||||
`Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded`
|
||||
)
|
||||
})
|
||||
|
||||
it('allows chain just under max depth', () => {
|
||||
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`)
|
||||
expect(validateCallChain(chain)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNextCallChain', () => {
|
||||
it('appends workflow ID to empty chain', () => {
|
||||
expect(buildNextCallChain([], 'wf-a')).toEqual(['wf-a'])
|
||||
})
|
||||
|
||||
it('appends workflow ID to existing chain', () => {
|
||||
expect(buildNextCallChain(['wf-a', 'wf-b'], 'wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c'])
|
||||
})
|
||||
|
||||
it('does not mutate the original chain', () => {
|
||||
const original = ['wf-a']
|
||||
const result = buildNextCallChain(original, 'wf-b')
|
||||
expect(original).toEqual(['wf-a'])
|
||||
expect(result).toEqual(['wf-a', 'wf-b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('parse → serialize is identity', () => {
|
||||
const header = 'wf-a,wf-b,wf-c'
|
||||
expect(serializeCallChain(parseCallChain(header))).toBe(header)
|
||||
})
|
||||
|
||||
it('serialize → parse is identity', () => {
|
||||
const chain = ['wf-a', 'wf-b', 'wf-c']
|
||||
expect(parseCallChain(serializeCallChain(chain))).toEqual(chain)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Workflow call chain detection using the Via-style pattern.
|
||||
*
|
||||
* Prevents infinite execution loops when workflows call each other via API or
|
||||
* MCP endpoints. Each hop appends the current workflow ID to the `X-Sim-Via`
|
||||
* header; on ingress the chain is checked for depth.
|
||||
*/
|
||||
|
||||
export const SIM_VIA_HEADER = 'X-Sim-Via'
|
||||
export const MAX_CALL_CHAIN_DEPTH = 25
|
||||
|
||||
/**
|
||||
* Parses the `X-Sim-Via` header value into an ordered list of workflow IDs.
|
||||
* Returns an empty array when the header is absent or empty.
|
||||
*/
|
||||
export function parseCallChain(headerValue: string | null | undefined): string[] {
|
||||
if (!headerValue || !headerValue.trim()) {
|
||||
return []
|
||||
}
|
||||
return headerValue
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a call chain array back into the header value format.
|
||||
*/
|
||||
export function serializeCallChain(chain: string[]): string {
|
||||
return chain.join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the call chain has not exceeded the maximum depth.
|
||||
* Returns an error message string if invalid, or `null` if the chain is
|
||||
* safe to extend.
|
||||
*/
|
||||
export function validateCallChain(chain: string[]): string | null {
|
||||
if (chain.length >= MAX_CALL_CHAIN_DEPTH) {
|
||||
return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the next call chain by appending the current workflow ID.
|
||||
*/
|
||||
export function buildNextCallChain(chain: string[], workflowId: string): string[] {
|
||||
return [...chain, workflowId]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRedisSet, mockPublish, mockSubscribe } = vi.hoisted(() => ({
|
||||
mockRedisSet: vi.fn(),
|
||||
mockPublish: vi.fn(),
|
||||
mockSubscribe: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
|
||||
vi.mock('@/lib/events/pubsub', () => ({
|
||||
createPubSubChannel: () => ({
|
||||
publish: mockPublish,
|
||||
subscribe: mockSubscribe,
|
||||
dispose: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { getCancellationChannel, markExecutionCancelled } from './cancellation'
|
||||
import {
|
||||
abortManualExecution,
|
||||
registerManualExecutionAborter,
|
||||
unregisterManualExecutionAborter,
|
||||
} from './manual-cancellation'
|
||||
|
||||
describe('markExecutionCancelled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns redis_unavailable when no Redis client exists', async () => {
|
||||
mockGetRedisClient.mockReturnValue(null)
|
||||
|
||||
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns recorded when Redis write succeeds', async () => {
|
||||
mockRedisSet.mockResolvedValue('OK')
|
||||
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
|
||||
|
||||
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
|
||||
durablyRecorded: true,
|
||||
reason: 'recorded',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns redis_write_failed when Redis write throws', async () => {
|
||||
mockRedisSet.mockRejectedValue(new Error('set failed'))
|
||||
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
|
||||
|
||||
await expect(markExecutionCancelled('execution-1')).resolves.toEqual({
|
||||
durablyRecorded: false,
|
||||
reason: 'redis_write_failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes even when the Redis write fails so local subscribers wake up', async () => {
|
||||
mockRedisSet.mockRejectedValue(new Error('set failed'))
|
||||
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
|
||||
|
||||
await markExecutionCancelled('execution-write-failed')
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledWith({ executionId: 'execution-write-failed' })
|
||||
})
|
||||
|
||||
it('publishes a cancellation event after a successful Redis write', async () => {
|
||||
mockRedisSet.mockResolvedValue('OK')
|
||||
mockGetRedisClient.mockReturnValue({ set: mockRedisSet })
|
||||
|
||||
await markExecutionCancelled('execution-2')
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledWith({ executionId: 'execution-2' })
|
||||
expect(mockRedisSet.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockPublish.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes even when Redis is unavailable so local subscribers wake up', async () => {
|
||||
mockGetRedisClient.mockReturnValue(null)
|
||||
|
||||
await markExecutionCancelled('execution-3')
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledWith({ executionId: 'execution-3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCancellationChannel', () => {
|
||||
it('returns the same channel instance across calls', () => {
|
||||
expect(getCancellationChannel()).toBe(getCancellationChannel())
|
||||
})
|
||||
})
|
||||
|
||||
describe('manual execution cancellation registry', () => {
|
||||
beforeEach(() => {
|
||||
unregisterManualExecutionAborter('execution-1')
|
||||
})
|
||||
|
||||
it('aborts registered executions', () => {
|
||||
const abort = vi.fn()
|
||||
|
||||
registerManualExecutionAborter('execution-1', abort)
|
||||
|
||||
expect(abortManualExecution('execution-1')).toBe(true)
|
||||
expect(abort).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns false when no execution is registered', () => {
|
||||
expect(abortManualExecution('execution-missing')).toBe(false)
|
||||
})
|
||||
|
||||
it('unregisters executions', () => {
|
||||
const abort = vi.fn()
|
||||
|
||||
registerManualExecutionAborter('execution-1', abort)
|
||||
unregisterManualExecutionAborter('execution-1')
|
||||
|
||||
expect(abortManualExecution('execution-1')).toBe(false)
|
||||
expect(abort).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub'
|
||||
|
||||
const logger = createLogger('ExecutionCancellation')
|
||||
|
||||
const EXECUTION_CANCEL_PREFIX = 'execution:cancel:'
|
||||
const EXECUTION_CANCEL_EXPIRY = 60 * 60
|
||||
const EXECUTION_CANCEL_CHANNEL = 'execution:cancel'
|
||||
|
||||
export interface ExecutionCancelEvent {
|
||||
executionId: string
|
||||
}
|
||||
|
||||
export type ExecutionCancellationRecordResult =
|
||||
| { durablyRecorded: true; reason: 'recorded' }
|
||||
| {
|
||||
durablyRecorded: false
|
||||
reason: 'redis_unavailable' | 'redis_write_failed'
|
||||
}
|
||||
|
||||
type CancellationGlobal = typeof globalThis & {
|
||||
_executionCancelChannel?: PubSubChannel<ExecutionCancelEvent>
|
||||
}
|
||||
|
||||
const _g = globalThis as CancellationGlobal
|
||||
|
||||
export function getCancellationChannel(): PubSubChannel<ExecutionCancelEvent> {
|
||||
if (!_g._executionCancelChannel) {
|
||||
_g._executionCancelChannel = createPubSubChannel<ExecutionCancelEvent>({
|
||||
channel: EXECUTION_CANCEL_CHANNEL,
|
||||
label: 'execution-cancel',
|
||||
})
|
||||
}
|
||||
return _g._executionCancelChannel
|
||||
}
|
||||
|
||||
export function isRedisCancellationEnabled(): boolean {
|
||||
return getRedisClient() !== null
|
||||
}
|
||||
|
||||
/** Writes the durable key first, then publishes — so a late subscriber still sees the flag on backstop check. */
|
||||
export async function markExecutionCancelled(
|
||||
executionId: string
|
||||
): Promise<ExecutionCancellationRecordResult> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
getCancellationChannel().publish({ executionId })
|
||||
return { durablyRecorded: false, reason: 'redis_unavailable' }
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.set(`${EXECUTION_CANCEL_PREFIX}${executionId}`, '1', 'EX', EXECUTION_CANCEL_EXPIRY)
|
||||
logger.info('Marked execution as cancelled', { executionId })
|
||||
getCancellationChannel().publish({ executionId })
|
||||
return { durablyRecorded: true, reason: 'recorded' }
|
||||
} catch (error) {
|
||||
logger.error('Failed to mark execution as cancelled', { executionId, error })
|
||||
getCancellationChannel().publish({ executionId })
|
||||
return { durablyRecorded: false, reason: 'redis_write_failed' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function isExecutionCancelled(executionId: string): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await redis.exists(`${EXECUTION_CANCEL_PREFIX}${executionId}`)
|
||||
return result === 1
|
||||
} catch (error) {
|
||||
logger.error('Failed to check execution cancellation', { executionId, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearExecutionCancellation(executionId: string): Promise<void> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.del(`${EXECUTION_CANCEL_PREFIX}${executionId}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear execution cancellation', { executionId, error })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
|
||||
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
|
||||
|
||||
export { DEFAULT_EXECUTION_TIMEOUT_MS }
|
||||
|
||||
/**
|
||||
* Maximum inline source size accepted by document preview endpoints.
|
||||
*
|
||||
* This is intentionally much lower than Next.js's default 10MB proxy body cap:
|
||||
* preview requests send user-authored source code, not binary uploads. Keeping
|
||||
* the limit at 1MB gives generous headroom for real PPTX/PDF generator scripts
|
||||
* while reducing memory pressure and abuse potential from oversized payloads.
|
||||
*/
|
||||
export const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1 * 1024 * 1024
|
||||
|
||||
/** Maps file extension to the sandbox task that compiles/generates that document type. */
|
||||
export const BINARY_DOC_TASKS: Record<string, SandboxTaskId> = {
|
||||
docx: 'docx-generate',
|
||||
pptx: 'pptx-generate',
|
||||
pdf: 'pdf-generate',
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
|
||||
const { mockCreate, mockRunCode, mockCommandsRun, mockFilesWrite, mockKill } = vi.hoisted(() => ({
|
||||
mockCreate: vi.fn(),
|
||||
mockRunCode: vi.fn(),
|
||||
mockCommandsRun: vi.fn(),
|
||||
mockFilesWrite: vi.fn(),
|
||||
mockKill: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@e2b/code-interpreter', () => ({ Sandbox: { create: mockCreate } }))
|
||||
vi.mock('@/lib/core/config/env', () => ({ env: { E2B_API_KEY: 'test-key' } }))
|
||||
|
||||
import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b'
|
||||
|
||||
describe('e2b sandbox inputs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreate.mockResolvedValue({
|
||||
sandboxId: 'sb_1',
|
||||
files: { write: mockFilesWrite },
|
||||
commands: { run: mockCommandsRun },
|
||||
runCode: mockRunCode,
|
||||
kill: mockKill,
|
||||
})
|
||||
mockRunCode.mockResolvedValue({
|
||||
error: null,
|
||||
text: '',
|
||||
logs: { stdout: [], stderr: [] },
|
||||
results: [],
|
||||
})
|
||||
// Default: shell code run + any fetch succeed.
|
||||
mockCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('fetches a url entry via curl with URL/DST/DIR passed as envs (no inline write)', async () => {
|
||||
await executeInE2B({
|
||||
code: 'x',
|
||||
language: CodeLanguage.JavaScript,
|
||||
timeoutMs: 1000,
|
||||
sandboxFiles: [
|
||||
{ type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p?a=1&b=2' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockCommandsRun).toHaveBeenCalledTimes(1)
|
||||
const [cmd, opts] = mockCommandsRun.mock.calls[0]
|
||||
expect(cmd).toContain('curl')
|
||||
expect(cmd).toContain('mkdir -p')
|
||||
// URL/path go through envs, never interpolated into the command string.
|
||||
expect(cmd).not.toContain('https://s3.example')
|
||||
expect(opts.envs).toEqual({
|
||||
URL: 'https://s3.example/p?a=1&b=2',
|
||||
DST: '/home/user/tables/t.csv',
|
||||
DIR: '/home/user/tables',
|
||||
})
|
||||
expect(opts.user).toBeUndefined() // code sandbox runs as default user
|
||||
expect(mockFilesWrite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes a content entry inline (no fetch)', async () => {
|
||||
await executeInE2B({
|
||||
code: 'x',
|
||||
language: CodeLanguage.JavaScript,
|
||||
timeoutMs: 1000,
|
||||
sandboxFiles: [{ path: '/home/user/f.txt', content: 'hi' }],
|
||||
})
|
||||
|
||||
expect(mockFilesWrite).toHaveBeenCalledWith('/home/user/f.txt', 'hi')
|
||||
expect(mockCommandsRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches as root in the shell sandbox', async () => {
|
||||
await executeShellInE2B({
|
||||
code: 'echo hi',
|
||||
envs: {},
|
||||
timeoutMs: 1000,
|
||||
sandboxFiles: [{ type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p' }],
|
||||
})
|
||||
|
||||
const fetchCall = mockCommandsRun.mock.calls.find((c) => c[1]?.envs?.URL)
|
||||
expect(fetchCall).toBeDefined()
|
||||
expect(fetchCall?.[0]).toContain('curl')
|
||||
expect(fetchCall?.[1].user).toBe('root')
|
||||
})
|
||||
|
||||
it('throws a clear error and kills the sandbox when the fetch fails', async () => {
|
||||
mockCommandsRun.mockRejectedValueOnce(new Error('curl: (22) 403'))
|
||||
|
||||
await expect(
|
||||
executeInE2B({
|
||||
code: 'x',
|
||||
language: CodeLanguage.JavaScript,
|
||||
timeoutMs: 1000,
|
||||
sandboxFiles: [
|
||||
{ type: 'url', path: '/home/user/tables/t.csv', url: 'https://s3.example/p' },
|
||||
],
|
||||
})
|
||||
).rejects.toThrow(/Failed to fetch mounted file into sandbox/)
|
||||
|
||||
expect(mockKill).toHaveBeenCalled()
|
||||
expect(mockRunCode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,476 @@
|
||||
import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
|
||||
/**
|
||||
* A sandbox input file. `content` entries are written inline; `url` entries are fetched from inside
|
||||
* the sandbox (so large mounts never pass their bytes through the web process).
|
||||
*/
|
||||
export type SandboxFile =
|
||||
| { type?: 'content'; path: string; content: string; encoding?: 'base64' }
|
||||
| { type: 'url'; path: string; url: string }
|
||||
|
||||
export interface E2BExecutionRequest {
|
||||
code: string
|
||||
language: CodeLanguage
|
||||
timeoutMs: number
|
||||
sandboxFiles?: SandboxFile[]
|
||||
outputSandboxPath?: string
|
||||
outputSandboxPaths?: string[]
|
||||
// Which sandbox template to run in. Defaults to 'code' (mothership-shell).
|
||||
// Document generation passes 'doc' so it runs in the doc template
|
||||
// (mothership-docs) that has python-pptx/docx/openpyxl/reportlab installed.
|
||||
sandboxKind?: 'code' | 'doc'
|
||||
}
|
||||
|
||||
export interface E2BShellExecutionRequest {
|
||||
code: string
|
||||
envs: Record<string, string>
|
||||
timeoutMs: number
|
||||
sandboxFiles?: SandboxFile[]
|
||||
outputSandboxPath?: string
|
||||
outputSandboxPaths?: string[]
|
||||
// Which sandbox template to run in. Defaults to 'shell' (mothership-shell).
|
||||
// The Node document engines (pptxgenjs/docx + react-icons/sharp) pass 'doc' so
|
||||
// they run in the doc template (mothership-docs).
|
||||
sandboxKind?: 'shell' | 'doc'
|
||||
}
|
||||
|
||||
export interface E2BExecutionResult {
|
||||
result: unknown
|
||||
stdout: string
|
||||
sandboxId?: string
|
||||
error?: string
|
||||
exportedFileContent?: string
|
||||
exportedFiles?: Record<string, string>
|
||||
/** Base64-encoded PNG images captured from rich outputs (e.g. matplotlib figures). */
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
const logger = createLogger('E2BExecution')
|
||||
|
||||
/**
|
||||
* Materializes sandbox input files before user code runs. `content` entries are written inline;
|
||||
* `url` entries are fetched from inside the sandbox via `curl` — their bytes never pass through the
|
||||
* web process, so the mount size is bounded by sandbox disk, not web heap. The URL and paths are
|
||||
* passed as env vars (never interpolated into the shell) so a presigned query string can't break or
|
||||
* inject. A failed fetch throws so user code never runs against a missing mount. `rootUser` matches
|
||||
* the shell sandbox's root execution context.
|
||||
*/
|
||||
async function writeSandboxInputs(
|
||||
sandbox: E2BSandbox,
|
||||
files: SandboxFile[] | undefined,
|
||||
opts: { sandboxId?: string; rootUser?: boolean }
|
||||
): Promise<void> {
|
||||
if (!files?.length) return
|
||||
const fetchedByUrl: string[] = []
|
||||
const writtenInline: string[] = []
|
||||
for (const file of files) {
|
||||
if (file.type === 'url') {
|
||||
const dir = file.path.slice(0, file.path.lastIndexOf('/'))
|
||||
try {
|
||||
await sandbox.commands.run(
|
||||
'set -e; [ -n "$DIR" ] && mkdir -p "$DIR"; curl -fsS --retry 3 --retry-connrefused --max-time 300 "$URL" -o "$DST"',
|
||||
{
|
||||
envs: { URL: file.url, DST: file.path, DIR: dir },
|
||||
...(opts.rootUser ? { user: 'root' } : {}),
|
||||
}
|
||||
)
|
||||
fetchedByUrl.push(file.path)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to fetch mounted file into sandbox at ${file.path}: ${getErrorMessage(error)}`
|
||||
)
|
||||
}
|
||||
} else if (file.encoding === 'base64') {
|
||||
const buf = Buffer.from(file.content, 'base64')
|
||||
await sandbox.files.write(
|
||||
file.path,
|
||||
buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
||||
)
|
||||
writtenInline.push(file.path)
|
||||
} else {
|
||||
await sandbox.files.write(file.path, file.content)
|
||||
writtenInline.push(file.path)
|
||||
}
|
||||
}
|
||||
// Split counts so it's visible whether a mount was fetched in-sandbox (by presigned URL, no bytes
|
||||
// through the web process) or written inline.
|
||||
logger.info('Materialized sandbox inputs', {
|
||||
sandboxId: opts.sandboxId,
|
||||
fetchedByUrlCount: fetchedByUrl.length,
|
||||
writtenInlineCount: writtenInline.length,
|
||||
fetchedByUrl,
|
||||
writtenInline,
|
||||
})
|
||||
}
|
||||
|
||||
async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise<E2BSandbox> {
|
||||
const apiKey = env.E2B_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error('E2B_API_KEY is required when E2B is enabled')
|
||||
}
|
||||
|
||||
// Document generation uses a dedicated template (python-pptx/docx/openpyxl/
|
||||
// reportlab + fonts); shell/code execution use the general shell template.
|
||||
// Doc fails closed: never run LLM-authored Python in E2B's default template
|
||||
// (which is not vetted for this) just because the doc template id is unset.
|
||||
if (kind === 'doc' && !env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) {
|
||||
throw new Error('Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)')
|
||||
}
|
||||
// Pi fails closed for the same reason: the coding agent needs the Pi CLI + git
|
||||
// baked into a vetted template, never E2B's default image.
|
||||
if (kind === 'pi' && !env.E2B_PI_TEMPLATE_ID) {
|
||||
throw new Error('Pi cloud agent not configured (E2B_PI_TEMPLATE_ID is unset)')
|
||||
}
|
||||
|
||||
const templateName =
|
||||
kind === 'doc'
|
||||
? env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID
|
||||
: kind === 'pi'
|
||||
? env.E2B_PI_TEMPLATE_ID
|
||||
: env.MOTHERSHIP_E2B_TEMPLATE_ID
|
||||
logger.info('Creating E2B sandbox', {
|
||||
kind,
|
||||
template: templateName || '(default)',
|
||||
})
|
||||
const { Sandbox } = await import('@e2b/code-interpreter')
|
||||
return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey })
|
||||
}
|
||||
|
||||
function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean {
|
||||
const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase()
|
||||
const binaryExts = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.pdf',
|
||||
'.zip',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.docx',
|
||||
'.pptx',
|
||||
'.xlsx',
|
||||
])
|
||||
return binaryExts.has(ext)
|
||||
}
|
||||
|
||||
async function readSandboxOutputFile(
|
||||
sandbox: E2BSandbox,
|
||||
outputSandboxPath: string,
|
||||
options?: { user?: string }
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
if (shouldReadSandboxPathAsBase64(outputSandboxPath)) {
|
||||
const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, options)
|
||||
return b64Result.stdout
|
||||
}
|
||||
return await sandbox.files.read(outputSandboxPath)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read requested sandbox output file', {
|
||||
outputSandboxPath,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function requestedOutputSandboxPaths(req: {
|
||||
outputSandboxPath?: string
|
||||
outputSandboxPaths?: string[]
|
||||
}): string[] {
|
||||
const paths = [...(req.outputSandboxPaths ?? [])]
|
||||
if (req.outputSandboxPath && !paths.includes(req.outputSandboxPath)) {
|
||||
paths.push(req.outputSandboxPath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function executeInE2B(req: E2BExecutionRequest): Promise<E2BExecutionResult> {
|
||||
const { code, language, timeoutMs } = req
|
||||
|
||||
const sandbox = await createE2BSandbox(req.sandboxKind ?? 'code')
|
||||
const sandboxId = sandbox.sandboxId
|
||||
|
||||
const stdoutChunks = []
|
||||
|
||||
try {
|
||||
// Inside the try so a failed mount still kills the sandbox via the finally below.
|
||||
await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId })
|
||||
|
||||
const execution = await sandbox.runCode(code, {
|
||||
language: language === CodeLanguage.Python ? 'python' : 'javascript',
|
||||
timeoutMs,
|
||||
})
|
||||
|
||||
if (execution.error) {
|
||||
const errorMessage = `${execution.error.name}: ${execution.error.value}`
|
||||
logger.error(`E2B execution error`, {
|
||||
sandboxId,
|
||||
error: execution.error,
|
||||
errorMessage,
|
||||
})
|
||||
|
||||
const errorOutput = execution.error.traceback || errorMessage
|
||||
return {
|
||||
result: null,
|
||||
stdout: errorOutput,
|
||||
error: errorMessage,
|
||||
sandboxId,
|
||||
}
|
||||
}
|
||||
|
||||
if (execution.text) {
|
||||
stdoutChunks.push(execution.text)
|
||||
}
|
||||
if (execution.logs?.stdout) {
|
||||
stdoutChunks.push(...execution.logs.stdout)
|
||||
}
|
||||
if (execution.logs?.stderr) {
|
||||
stdoutChunks.push(...execution.logs.stderr)
|
||||
}
|
||||
|
||||
const stdout = stdoutChunks.join('\n')
|
||||
|
||||
let result: unknown = null
|
||||
const prefix = '__SIM_RESULT__='
|
||||
const lines = stdout.split('\n')
|
||||
const marker = lines.find((l) => l.startsWith(prefix))
|
||||
let cleanedStdout = stdout
|
||||
if (marker) {
|
||||
const jsonPart = marker.slice(prefix.length)
|
||||
try {
|
||||
result = JSON.parse(jsonPart)
|
||||
} catch {
|
||||
result = jsonPart
|
||||
}
|
||||
const filteredLines = lines.filter((l) => !l.startsWith(prefix))
|
||||
if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') {
|
||||
filteredLines.pop()
|
||||
}
|
||||
cleanedStdout = filteredLines.join('\n')
|
||||
}
|
||||
|
||||
const images: string[] = []
|
||||
if (execution.results?.length) {
|
||||
for (const r of execution.results) {
|
||||
if (r.png) {
|
||||
images.push(r.png)
|
||||
} else if (r.jpeg) {
|
||||
images.push(r.jpeg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exportedFiles: Record<string, string> = {}
|
||||
for (const outputSandboxPath of requestedOutputSandboxPaths(req)) {
|
||||
const content = await readSandboxOutputFile(sandbox, outputSandboxPath)
|
||||
if (content !== undefined) {
|
||||
exportedFiles[outputSandboxPath] = content
|
||||
}
|
||||
}
|
||||
const exportedFileContent = req.outputSandboxPath
|
||||
? exportedFiles[req.outputSandboxPath]
|
||||
: undefined
|
||||
|
||||
return {
|
||||
result,
|
||||
stdout: cleanedStdout,
|
||||
sandboxId,
|
||||
exportedFileContent,
|
||||
exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined,
|
||||
images: images.length ? images : undefined,
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await sandbox.kill()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeShellInE2B(
|
||||
req: E2BShellExecutionRequest
|
||||
): Promise<E2BExecutionResult> {
|
||||
const { code, envs, timeoutMs } = req
|
||||
|
||||
const sandbox = await createE2BSandbox(req.sandboxKind ?? 'shell')
|
||||
const sandboxId = sandbox.sandboxId
|
||||
|
||||
try {
|
||||
// Inside the try so a failed mount still kills the sandbox via the finally below.
|
||||
await writeSandboxInputs(sandbox, req.sandboxFiles, { sandboxId, rootUser: true })
|
||||
|
||||
let result: { stdout: string; stderr: string; exitCode: number }
|
||||
try {
|
||||
result = await sandbox.commands.run(code, {
|
||||
envs: {
|
||||
...envs,
|
||||
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin',
|
||||
},
|
||||
timeoutMs,
|
||||
user: 'root',
|
||||
})
|
||||
} catch (cmdError: any) {
|
||||
const stderr = cmdError?.stderr || cmdError?.message || String(cmdError)
|
||||
const stdout = cmdError?.stdout || ''
|
||||
const exitCode = cmdError?.exitCode ?? 1
|
||||
logger.error('E2B shell command error', {
|
||||
sandboxId,
|
||||
exitCode,
|
||||
error: stderr.slice(0, 500),
|
||||
})
|
||||
return {
|
||||
result: null,
|
||||
stdout: [stdout, stderr].filter(Boolean).join('\n'),
|
||||
error: stderr || `Command failed with exit code ${exitCode}`,
|
||||
sandboxId,
|
||||
}
|
||||
}
|
||||
|
||||
const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n')
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorMessage = result.stderr || `Process exited with code ${result.exitCode}`
|
||||
logger.error('E2B shell execution error', {
|
||||
sandboxId,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr?.slice(0, 500),
|
||||
})
|
||||
return {
|
||||
result: null,
|
||||
stdout,
|
||||
error: errorMessage,
|
||||
sandboxId,
|
||||
}
|
||||
}
|
||||
|
||||
let parsed: unknown = null
|
||||
const prefix = '__SIM_RESULT__='
|
||||
const lines = stdout.split('\n')
|
||||
const marker = lines.find((l) => l.startsWith(prefix))
|
||||
let cleanedStdout = stdout
|
||||
if (marker) {
|
||||
const jsonPart = marker.slice(prefix.length)
|
||||
try {
|
||||
parsed = JSON.parse(jsonPart)
|
||||
} catch {
|
||||
parsed = jsonPart
|
||||
}
|
||||
const filteredLines = lines.filter((l) => !l.startsWith(prefix))
|
||||
if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') {
|
||||
filteredLines.pop()
|
||||
}
|
||||
cleanedStdout = filteredLines.join('\n')
|
||||
}
|
||||
|
||||
const exportedFiles: Record<string, string> = {}
|
||||
for (const outputSandboxPath of requestedOutputSandboxPaths(req)) {
|
||||
const content = await readSandboxOutputFile(sandbox, outputSandboxPath, {
|
||||
user: 'root',
|
||||
})
|
||||
if (content !== undefined) {
|
||||
exportedFiles[outputSandboxPath] = content
|
||||
}
|
||||
}
|
||||
const exportedFileContent = req.outputSandboxPath
|
||||
? exportedFiles[req.outputSandboxPath]
|
||||
: undefined
|
||||
|
||||
return {
|
||||
result: parsed,
|
||||
stdout: cleanedStdout,
|
||||
sandboxId,
|
||||
exportedFileContent,
|
||||
exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined,
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await sandbox.kill()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const PI_SANDBOX_PATH =
|
||||
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin'
|
||||
|
||||
/** Result of one command run inside a Pi sandbox. */
|
||||
export interface PiSandboxCommandResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
/** Runs commands and moves files inside a live Pi sandbox. */
|
||||
export interface PiSandboxRunner {
|
||||
run(
|
||||
command: string,
|
||||
options: {
|
||||
envs?: Record<string, string>
|
||||
timeoutMs: number
|
||||
onStdout?: (chunk: string) => void
|
||||
onStderr?: (chunk: string) => void
|
||||
}
|
||||
): Promise<PiSandboxCommandResult>
|
||||
readFile(path: string): Promise<string>
|
||||
/**
|
||||
* Writes a file via the sandbox filesystem API. Bytes go through the E2B SDK,
|
||||
* never a shell, so untrusted content (the assembled prompt, a commit message)
|
||||
* is delivered without any shell parsing — callers reference it by a fixed path.
|
||||
*/
|
||||
writeFile(path: string, content: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Pi sandbox, keeps it alive for the duration of `fn` (so the cloned
|
||||
* repo persists across the clone -> agent -> push commands), streams command
|
||||
* output, and always kills the sandbox afterward. Per-command envs are isolated,
|
||||
* so secrets handed to one command never leak into the next.
|
||||
*/
|
||||
export async function withPiSandbox<T>(fn: (runner: PiSandboxRunner) => Promise<T>): Promise<T> {
|
||||
const sandbox = await createE2BSandbox('pi')
|
||||
const sandboxId = sandbox.sandboxId
|
||||
logger.info('Started Pi sandbox', { sandboxId })
|
||||
|
||||
const runner: PiSandboxRunner = {
|
||||
run: async (command, options) => {
|
||||
try {
|
||||
const result = await sandbox.commands.run(command, {
|
||||
envs: { ...(options.envs ?? {}), PATH: PI_SANDBOX_PATH },
|
||||
timeoutMs: options.timeoutMs,
|
||||
user: 'root',
|
||||
onStdout: options.onStdout,
|
||||
onStderr: options.onStderr,
|
||||
})
|
||||
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
|
||||
} catch (error) {
|
||||
const failure = error as {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
message?: string
|
||||
exitCode?: number
|
||||
}
|
||||
return {
|
||||
stdout: failure.stdout ?? '',
|
||||
stderr: failure.stderr ?? failure.message ?? getErrorMessage(error),
|
||||
exitCode: failure.exitCode ?? 1,
|
||||
}
|
||||
}
|
||||
},
|
||||
readFile: (path) => sandbox.files.read(path),
|
||||
writeFile: async (path, content) => {
|
||||
await sandbox.files.write(path, content)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn(runner)
|
||||
} finally {
|
||||
try {
|
||||
await sandbox.kill()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionEventEntry } from '@/lib/execution/event-buffer'
|
||||
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
|
||||
|
||||
const { mockGetRedisClient, mockRedis, persistedEntries } = vi.hoisted(() => {
|
||||
const persistedEntries: ExecutionEventEntry[] = []
|
||||
const mockRedis = {
|
||||
get: vi.fn(),
|
||||
incrby: vi.fn(),
|
||||
hset: vi.fn(),
|
||||
expire: vi.fn(),
|
||||
hgetall: vi.fn(),
|
||||
zrangebyscore: vi.fn(),
|
||||
zremrangebyrank: vi.fn(),
|
||||
pipeline: vi.fn(),
|
||||
eval: vi.fn(),
|
||||
}
|
||||
const mockGetRedisClient = vi.fn(() => mockRedis)
|
||||
return { mockGetRedisClient, mockRedis, persistedEntries }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => ({
|
||||
getRedisClient: mockGetRedisClient,
|
||||
}))
|
||||
|
||||
import {
|
||||
createExecutionEventWriter,
|
||||
flushExecutionStreamReplayBuffer,
|
||||
initializeExecutionStreamMeta,
|
||||
readExecutionEventsState,
|
||||
resetExecutionStreamBuffer,
|
||||
} from '@/lib/execution/event-buffer'
|
||||
|
||||
function makeEvent(blockId: string): ExecutionEvent {
|
||||
return {
|
||||
type: 'block:started',
|
||||
timestamp: new Date().toISOString(),
|
||||
executionId: 'exec-1',
|
||||
workflowId: 'wf-1',
|
||||
data: {
|
||||
blockId,
|
||||
blockName: blockId,
|
||||
blockType: 'function',
|
||||
executionOrder: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseFlushEvalArgs(args: unknown[]): {
|
||||
terminalStatus: string
|
||||
zaddArgs: (string | number)[]
|
||||
} {
|
||||
const keyCount = Number(args[0])
|
||||
return {
|
||||
terminalStatus: String(args[keyCount + 4] ?? ''),
|
||||
zaddArgs: args.slice(keyCount + 9) as (string | number)[],
|
||||
}
|
||||
}
|
||||
|
||||
function isFlushScript(script: string): boolean {
|
||||
return script.includes("redis.call('ZADD'") && script.includes('new_count')
|
||||
}
|
||||
|
||||
function isResetScript(script: string): boolean {
|
||||
return script.includes('retained_bytes') && script.includes('replayStartEventId')
|
||||
}
|
||||
|
||||
describe('execution event buffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistedEntries.length = 0
|
||||
mockGetRedisClient.mockReturnValue(mockRedis)
|
||||
mockRedis.get.mockResolvedValue(null)
|
||||
mockRedis.hgetall.mockResolvedValue({})
|
||||
mockRedis.zrangebyscore.mockResolvedValue([])
|
||||
mockRedis.zremrangebyrank.mockResolvedValue(0)
|
||||
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
|
||||
if (isFlushScript(script)) {
|
||||
const { terminalStatus, zaddArgs } = parseFlushEvalArgs(args)
|
||||
for (let i = 0; i < zaddArgs.length; i += 2) {
|
||||
persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
|
||||
}
|
||||
if (terminalStatus) {
|
||||
await mockRedis.hset('meta', { status: terminalStatus })
|
||||
}
|
||||
return [1, persistedEntries[0]?.eventId ?? false, 0]
|
||||
}
|
||||
if (isResetScript(script)) {
|
||||
return 0
|
||||
}
|
||||
if (script.includes('DECRBY')) {
|
||||
return 1
|
||||
}
|
||||
return [1, 'ok', 0, 0]
|
||||
})
|
||||
mockRedis.pipeline.mockImplementation(() => ({
|
||||
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
persistedEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
|
||||
}
|
||||
}),
|
||||
expire: vi.fn(),
|
||||
zremrangebyrank: vi.fn(),
|
||||
exec: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
})
|
||||
|
||||
it('serializes event id reservation so reconnect replay preserves write order', async () => {
|
||||
let releaseReservation: ((value: number) => void) | undefined
|
||||
mockRedis.incrby.mockReturnValueOnce(
|
||||
new Promise<number>((resolve) => {
|
||||
releaseReservation = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
const firstWrite = writer.write(makeEvent('first'))
|
||||
const secondWrite = writer.write(makeEvent('second'))
|
||||
|
||||
await Promise.resolve()
|
||||
expect(mockRedis.incrby).toHaveBeenCalledTimes(1)
|
||||
|
||||
releaseReservation?.(100)
|
||||
await expect(Promise.all([firstWrite, secondWrite])).resolves.toMatchObject([
|
||||
{ eventId: 1 },
|
||||
{ eventId: 2 },
|
||||
])
|
||||
|
||||
await writer.close()
|
||||
|
||||
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1, 2])
|
||||
expect(
|
||||
persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId)
|
||||
).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('flush waits for queued writes before returning', async () => {
|
||||
let releaseReservation: ((value: number) => void) | undefined
|
||||
mockRedis.incrby.mockReturnValueOnce(
|
||||
new Promise<number>((resolve) => {
|
||||
releaseReservation = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
const write = writer.write(makeEvent('terminal'))
|
||||
const flush = writer.flush()
|
||||
|
||||
await Promise.resolve()
|
||||
expect(persistedEntries).toEqual([])
|
||||
|
||||
releaseReservation?.(100)
|
||||
await write
|
||||
await flush
|
||||
|
||||
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
|
||||
expect((persistedEntries[0].event.data as { blockId: string }).blockId).toBe('terminal')
|
||||
})
|
||||
|
||||
it('flush drains events appended while another flush is in flight', async () => {
|
||||
mockRedis.incrby.mockResolvedValue(100)
|
||||
let releaseFirstFlush: (() => void) | undefined
|
||||
const execCalls: Array<() => Promise<void>> = [
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirstFlush = resolve
|
||||
}),
|
||||
() => Promise.resolve(),
|
||||
]
|
||||
|
||||
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
|
||||
const batchEntries: ExecutionEventEntry[] = []
|
||||
const { zaddArgs } = parseFlushEvalArgs(args)
|
||||
for (let i = 0; i < zaddArgs.length; i += 2) {
|
||||
batchEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
|
||||
}
|
||||
await (execCalls.shift() ?? (() => Promise.resolve()))()
|
||||
persistedEntries.push(...batchEntries)
|
||||
return [1, persistedEntries[0]?.eventId ?? false, 0]
|
||||
})
|
||||
mockRedis.pipeline.mockImplementation(() => {
|
||||
const batchEntries: ExecutionEventEntry[] = []
|
||||
return {
|
||||
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
batchEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
|
||||
}
|
||||
}),
|
||||
expire: vi.fn(),
|
||||
zremrangebyrank: vi.fn(),
|
||||
exec: vi.fn(async () => {
|
||||
await (execCalls.shift() ?? (() => Promise.resolve()))()
|
||||
persistedEntries.push(...batchEntries)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
await writer.write(makeEvent('first'))
|
||||
const firstFlush = writer.flush()
|
||||
|
||||
await Promise.resolve()
|
||||
expect(persistedEntries).toEqual([])
|
||||
|
||||
await writer.write(makeEvent('terminal'))
|
||||
const terminalFlush = writer.flush()
|
||||
|
||||
releaseFirstFlush?.()
|
||||
await firstFlush
|
||||
await terminalFlush
|
||||
|
||||
expect(
|
||||
persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId)
|
||||
).toEqual(['first', 'terminal'])
|
||||
})
|
||||
|
||||
it('flush surfaces queued write failures', async () => {
|
||||
mockRedis.incrby.mockRejectedValueOnce(new Error('redis reservation failed'))
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
await expect(writer.write(makeEvent('lost'))).rejects.toThrow('redis reservation failed')
|
||||
await expect(writer.flush()).rejects.toThrow('redis reservation failed')
|
||||
})
|
||||
|
||||
it('allows terminal finalization after a recovered queued write failure', async () => {
|
||||
mockRedis.incrby
|
||||
.mockRejectedValueOnce(new Error('redis reservation failed'))
|
||||
.mockResolvedValueOnce(200)
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
await expect(writer.write(makeEvent('lost'))).rejects.toThrow('redis reservation failed')
|
||||
await writer.write(makeEvent('terminal'))
|
||||
|
||||
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(true)
|
||||
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([101])
|
||||
expect(mockRedis.hset).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ status: 'complete' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not write terminal meta when the final replay flush fails', async () => {
|
||||
mockRedis.incrby.mockResolvedValue(100)
|
||||
mockRedis.eval.mockRejectedValue(new Error('redis flush failed'))
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
await writer.write(makeEvent('terminal'))
|
||||
|
||||
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(false)
|
||||
expect(mockRedis.hset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes replay events after a recovered final replay flush without terminal meta', async () => {
|
||||
mockRedis.incrby.mockResolvedValue(100)
|
||||
let flushAttempt = 0
|
||||
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
|
||||
const { zaddArgs } = parseFlushEvalArgs(args)
|
||||
if (flushAttempt > 0) {
|
||||
for (let i = 0; i < zaddArgs.length; i += 2) {
|
||||
persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
|
||||
}
|
||||
}
|
||||
if (flushAttempt++ === 0) {
|
||||
throw new Error('first flush failed')
|
||||
}
|
||||
return [1, persistedEntries[0]?.eventId ?? false, 0]
|
||||
})
|
||||
mockRedis.pipeline.mockImplementation(() => ({
|
||||
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
|
||||
if (flushAttempt > 0) {
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
persistedEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
|
||||
}
|
||||
}
|
||||
}),
|
||||
expire: vi.fn(),
|
||||
zremrangebyrank: vi.fn(),
|
||||
exec: vi.fn(async () => {
|
||||
if (flushAttempt++ === 0) {
|
||||
throw new Error('first flush failed')
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
await writer.write(makeEvent('terminal'))
|
||||
|
||||
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(true)
|
||||
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
|
||||
expect(mockRedis.hset).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ status: 'complete' })
|
||||
)
|
||||
})
|
||||
|
||||
it('writes terminal event and terminal meta atomically through writeTerminal', async () => {
|
||||
mockRedis.incrby.mockResolvedValue(100)
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
await writer.writeTerminal(makeEvent('terminal'), 'complete')
|
||||
|
||||
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith('meta', { status: 'complete' })
|
||||
})
|
||||
|
||||
it('budgets only net event bytes after pruning during flush', async () => {
|
||||
mockRedis.incrby.mockResolvedValue(100)
|
||||
let netBudgetBytes = 0
|
||||
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
|
||||
const keyCount = Number(args[0])
|
||||
netBudgetBytes = Number(args[keyCount + 5])
|
||||
const { zaddArgs } = parseFlushEvalArgs(args)
|
||||
for (let i = 0; i < zaddArgs.length; i += 2) {
|
||||
persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
|
||||
}
|
||||
return [1, persistedEntries[0]?.eventId ?? false, 123]
|
||||
})
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
await writer.writeTerminal(makeEvent('terminal'), 'complete')
|
||||
|
||||
expect(netBudgetBytes).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('releases retained event budget when resetting the stream buffer', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(41)
|
||||
mockRedis.hgetall.mockResolvedValueOnce({ userId: 'user-1' })
|
||||
let releasedBytes = 0
|
||||
mockRedis.eval.mockImplementationOnce(async (script: string, ...args: unknown[]) => {
|
||||
expect(script).toContain('retained_bytes')
|
||||
expect(args.slice(0, 5)).toEqual([
|
||||
4,
|
||||
'execution:stream:exec-1:events',
|
||||
'execution:stream:exec-1:meta',
|
||||
'execution:redis-budget:execution:exec-1',
|
||||
'execution:redis-budget:user:user-1',
|
||||
])
|
||||
releasedBytes = 256
|
||||
return releasedBytes
|
||||
})
|
||||
|
||||
await expect(resetExecutionStreamBuffer('exec-1')).resolves.toBe(true)
|
||||
|
||||
expect(releasedBytes).toBe(256)
|
||||
})
|
||||
|
||||
it('surfaces execution memory limit errors when the Redis budget is exceeded', async () => {
|
||||
mockRedis.incrby.mockResolvedValue(100)
|
||||
mockRedis.eval.mockImplementation(async (script: string) => {
|
||||
if (isFlushScript(script)) {
|
||||
return [0, 'execution_redis_bytes', 64 * 1024 * 1024]
|
||||
}
|
||||
return [1, 'ok', 0, 0]
|
||||
})
|
||||
|
||||
const writer = createExecutionEventWriter('exec-1')
|
||||
|
||||
await expect(writer.writeTerminal(makeEvent('terminal'), 'complete')).rejects.toThrow(
|
||||
'Execution memory limit exceeded'
|
||||
)
|
||||
expect(persistedEntries).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves requested UserFile base64 when buffering terminal events', async () => {
|
||||
mockRedis.incrby.mockResolvedValue(100)
|
||||
const base64 = Buffer.from('hello').toString('base64')
|
||||
const writer = createExecutionEventWriter('exec-1', { preserveUserFileBase64: true })
|
||||
|
||||
await writer.writeTerminal(
|
||||
{
|
||||
type: 'execution:completed',
|
||||
timestamp: new Date().toISOString(),
|
||||
executionId: 'exec-1',
|
||||
workflowId: 'wf-1',
|
||||
data: {
|
||||
success: true,
|
||||
duration: 1,
|
||||
output: {
|
||||
file: {
|
||||
id: 'file-1',
|
||||
name: 'small.txt',
|
||||
size: 5,
|
||||
type: 'text/plain',
|
||||
context: 'execution',
|
||||
base64,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const eventData = persistedEntries[0].event.data as {
|
||||
output: { file: { base64?: string } }
|
||||
}
|
||||
expect(eventData.output.file.base64).toBe(base64)
|
||||
})
|
||||
|
||||
it('retries active meta initialization before giving up', async () => {
|
||||
mockRedis.hset.mockRejectedValueOnce(new Error('meta write failed')).mockResolvedValueOnce(1)
|
||||
|
||||
await expect(
|
||||
initializeExecutionStreamMeta('exec-1', { userId: 'user-1', workflowId: 'wf-1' })
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledTimes(2)
|
||||
expect(mockRedis.hset).toHaveBeenLastCalledWith(
|
||||
'execution:stream:exec-1:meta',
|
||||
expect.objectContaining({
|
||||
status: 'active',
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports pruned replay buffers before reading incomplete events', async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({ status: 'active', earliestEventId: '10' })
|
||||
|
||||
await expect(readExecutionEventsState('exec-1', 0)).resolves.toEqual({
|
||||
status: 'pruned',
|
||||
earliestEventId: 10,
|
||||
})
|
||||
expect(mockRedis.zrangebyscore).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
|
||||
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('ExecutionFiles')
|
||||
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
/**
|
||||
* Process a single file for workflow execution - handles base64 ('file' type) and URL downloads ('url' type)
|
||||
*/
|
||||
export async function processExecutionFile(
|
||||
file: { type: string; data: string; name: string; mime?: string },
|
||||
executionContext: { workspaceId: string; workflowId: string; executionId: string },
|
||||
requestId: string,
|
||||
userId?: string
|
||||
): Promise<UserFile | null> {
|
||||
if (file.type === 'file' && file.data && file.name) {
|
||||
const dataUrlPrefix = 'data:'
|
||||
const base64Prefix = ';base64,'
|
||||
|
||||
if (!file.data.startsWith(dataUrlPrefix)) {
|
||||
logger.warn(`[${requestId}] Invalid data format for file: ${file.name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const base64Index = file.data.indexOf(base64Prefix)
|
||||
if (base64Index === -1) {
|
||||
logger.warn(`[${requestId}] Invalid data format (no base64 marker) for file: ${file.name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = file.data.substring(dataUrlPrefix.length, base64Index)
|
||||
const base64Data = file.data.substring(base64Index + base64Prefix.length)
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
if (buffer.length > MAX_FILE_SIZE) {
|
||||
const fileSizeMB = (buffer.length / (1024 * 1024)).toFixed(2)
|
||||
throw new Error(
|
||||
`File "${file.name}" exceeds the maximum size limit of 20MB (actual size: ${fileSizeMB}MB)`
|
||||
)
|
||||
}
|
||||
|
||||
const userFile = await uploadExecutionFile(
|
||||
executionContext,
|
||||
buffer,
|
||||
file.name,
|
||||
mimeType || file.mime || 'application/octet-stream',
|
||||
userId
|
||||
)
|
||||
|
||||
return userFile
|
||||
}
|
||||
|
||||
if (file.type === 'url' && file.data) {
|
||||
const { downloadFileFromUrl } = await import('@/lib/uploads/utils/file-utils.server')
|
||||
const buffer = await downloadFileFromUrl(file.data, { userId })
|
||||
|
||||
if (buffer.length > MAX_FILE_SIZE) {
|
||||
const fileSizeMB = (buffer.length / (1024 * 1024)).toFixed(2)
|
||||
throw new Error(
|
||||
`File "${file.name}" exceeds the maximum size limit of 20MB (actual size: ${fileSizeMB}MB)`
|
||||
)
|
||||
}
|
||||
|
||||
const userFile = await uploadExecutionFile(
|
||||
executionContext,
|
||||
buffer,
|
||||
file.name,
|
||||
file.mime || 'application/octet-stream',
|
||||
userId
|
||||
)
|
||||
|
||||
return userFile
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all files for a given field in workflow execution input
|
||||
*/
|
||||
export async function processExecutionFiles(
|
||||
fieldValue: any,
|
||||
executionContext: { workspaceId: string; workflowId: string; executionId: string },
|
||||
requestId: string,
|
||||
userId?: string
|
||||
): Promise<UserFile[]> {
|
||||
if (!fieldValue || typeof fieldValue !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const files = Array.isArray(fieldValue) ? fieldValue : [fieldValue]
|
||||
const uploadedFiles: UserFile[] = []
|
||||
const fullContext = { ...executionContext }
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const userFile = await processExecutionFile(file, fullContext, requestId, userId)
|
||||
|
||||
if (userFile) {
|
||||
uploadedFiles.push(userFile)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to process file ${file.name}:`, error)
|
||||
throw new Error(`Failed to upload file: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inputFormat fields from a start block or trigger block
|
||||
*/
|
||||
type ValidatedInputFormatField = Required<Pick<InputFormatField, 'name' | 'type'>>
|
||||
|
||||
function extractInputFormatFromBlock(block: SerializedBlock): ValidatedInputFormatField[] {
|
||||
const metadata = block.metadata as { subBlocks?: Record<string, { value?: unknown }> } | undefined
|
||||
const subBlocksValue = metadata?.subBlocks?.inputFormat?.value
|
||||
const legacyValue = block.config?.params?.inputFormat
|
||||
const inputFormatValue = subBlocksValue ?? legacyValue
|
||||
|
||||
if (!Array.isArray(inputFormatValue) || inputFormatValue.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return inputFormatValue.filter(
|
||||
(field): field is ValidatedInputFormatField =>
|
||||
field &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
'type' in field &&
|
||||
typeof field.name === 'string' &&
|
||||
typeof field.type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process file fields in workflow input based on the start block's inputFormat
|
||||
* This handles base64 and URL file inputs from API calls
|
||||
*/
|
||||
export async function processInputFileFields(
|
||||
input: unknown,
|
||||
blocks: SerializedBlock[],
|
||||
executionContext: { workspaceId: string; workflowId: string; executionId: string },
|
||||
requestId: string,
|
||||
userId?: string
|
||||
): Promise<unknown> {
|
||||
if (!input || typeof input !== 'object' || blocks.length === 0) {
|
||||
return input
|
||||
}
|
||||
|
||||
const startBlock = blocks.find((block) => {
|
||||
const blockType = block.metadata?.id
|
||||
return (
|
||||
blockType === TRIGGER_TYPES.START ||
|
||||
blockType === TRIGGER_TYPES.API ||
|
||||
blockType === TRIGGER_TYPES.INPUT ||
|
||||
blockType === TRIGGER_TYPES.GENERIC_WEBHOOK ||
|
||||
blockType === TRIGGER_TYPES.STARTER
|
||||
)
|
||||
})
|
||||
|
||||
if (!startBlock) {
|
||||
return input
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlock(startBlock)
|
||||
const fileFields = inputFormat.filter((field) => field.type === 'file[]')
|
||||
|
||||
if (fileFields.length === 0) {
|
||||
return input
|
||||
}
|
||||
|
||||
const processedInput = { ...input } as Record<string, unknown>
|
||||
|
||||
for (const fileField of fileFields) {
|
||||
const fieldValue = processedInput[fileField.name]
|
||||
|
||||
if (fieldValue && typeof fieldValue === 'object') {
|
||||
const uploadedFiles = await processExecutionFiles(
|
||||
fieldValue,
|
||||
executionContext,
|
||||
requestId,
|
||||
userId
|
||||
)
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
processedInput[fileField.name] = uploadedFiles
|
||||
logger.info(
|
||||
`[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedInput
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
inputValidationMock,
|
||||
inputValidationMockFns,
|
||||
redisConfigMock,
|
||||
redisConfigMockFns,
|
||||
} from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockProc = EventEmitter & {
|
||||
connected: boolean
|
||||
stderr: EventEmitter
|
||||
send: (message: unknown) => boolean
|
||||
kill: () => boolean
|
||||
}
|
||||
|
||||
type SpawnFactory = () => MockProc
|
||||
type RedisEval = (...args: unknown[]) => unknown | Promise<unknown>
|
||||
type SecureFetchImpl = (...args: unknown[]) => unknown | Promise<unknown>
|
||||
|
||||
function createBaseProc(): MockProc {
|
||||
const proc = new EventEmitter() as MockProc
|
||||
proc.connected = true
|
||||
proc.stderr = new EventEmitter()
|
||||
proc.send = () => true
|
||||
proc.kill = () => {
|
||||
if (!proc.connected) return true
|
||||
proc.connected = false
|
||||
setImmediate(() => proc.emit('exit', 0))
|
||||
return true
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
function createStartupFailureProc(): MockProc {
|
||||
const proc = createBaseProc()
|
||||
setImmediate(() => {
|
||||
proc.connected = false
|
||||
proc.emit('exit', 1)
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
function createReadyProc(result: unknown): MockProc {
|
||||
const proc = createBaseProc()
|
||||
proc.send = (message: unknown) => {
|
||||
const msg = message as { type?: string; executionId?: number }
|
||||
if (msg.type === 'execute') {
|
||||
setImmediate(() => {
|
||||
proc.emit('message', {
|
||||
type: 'result',
|
||||
executionId: msg.executionId,
|
||||
result: { result, stdout: '' },
|
||||
})
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
setImmediate(() => proc.emit('message', { type: 'ready' }))
|
||||
return proc
|
||||
}
|
||||
|
||||
function createReadyProcWithDelay(delayMs: number): MockProc {
|
||||
const proc = createBaseProc()
|
||||
proc.send = (message: unknown) => {
|
||||
const msg = message as { type?: string; executionId?: number; request?: { requestId?: string } }
|
||||
if (msg.type === 'execute') {
|
||||
setTimeout(() => {
|
||||
proc.emit('message', {
|
||||
type: 'result',
|
||||
executionId: msg.executionId,
|
||||
result: { result: msg.request?.requestId ?? 'unknown', stdout: '' },
|
||||
})
|
||||
}, delayMs)
|
||||
}
|
||||
return true
|
||||
}
|
||||
setImmediate(() => proc.emit('message', { type: 'ready' }))
|
||||
return proc
|
||||
}
|
||||
|
||||
type ControllableReadyProc = {
|
||||
spawn: SpawnFactory
|
||||
dispatched: Promise<void>
|
||||
release: (result?: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready worker whose execution is held open until {@link ControllableReadyProc.release}
|
||||
* is called. {@link ControllableReadyProc.dispatched} resolves the moment the worker
|
||||
* receives its `execute` message — i.e. once the scheduler has counted the execution as
|
||||
* active — giving tests a deterministic, event-driven signal that the concurrency slot is
|
||||
* occupied without relying on wall-clock delays. The proc is built inside {@link spawn} so
|
||||
* its `ready` event is emitted only after the module attaches its listeners.
|
||||
*/
|
||||
function createControllableReadyProc(): ControllableReadyProc {
|
||||
let markDispatched: () => void = () => {}
|
||||
const dispatched = new Promise<void>((resolve) => {
|
||||
markDispatched = resolve
|
||||
})
|
||||
let proc: MockProc | undefined
|
||||
let lastExecutionId = 0
|
||||
|
||||
const spawn: SpawnFactory = () => {
|
||||
const current = createBaseProc()
|
||||
current.send = (message: unknown) => {
|
||||
const msg = message as { type?: string; executionId?: number }
|
||||
if (msg.type === 'execute') {
|
||||
lastExecutionId = msg.executionId ?? 0
|
||||
markDispatched()
|
||||
}
|
||||
return true
|
||||
}
|
||||
setImmediate(() => current.emit('message', { type: 'ready' }))
|
||||
proc = current
|
||||
return current
|
||||
}
|
||||
|
||||
const release = (result: unknown = 'released') => {
|
||||
proc?.emit('message', {
|
||||
type: 'result',
|
||||
executionId: lastExecutionId,
|
||||
result: { result, stdout: '' },
|
||||
})
|
||||
}
|
||||
|
||||
return { spawn, dispatched, release }
|
||||
}
|
||||
|
||||
function createReadyFetchProxyProc(fetchMessage: { url: string; optionsJson?: string }): MockProc {
|
||||
const proc = createBaseProc()
|
||||
let currentExecutionId = 0
|
||||
|
||||
proc.send = (message: unknown) => {
|
||||
const msg = message as { type?: string; executionId?: number; request?: { requestId?: string } }
|
||||
|
||||
if (msg.type === 'execute') {
|
||||
currentExecutionId = msg.executionId ?? 0
|
||||
setImmediate(() => {
|
||||
proc.emit('message', {
|
||||
type: 'fetch',
|
||||
fetchId: 1,
|
||||
requestId: msg.request?.requestId ?? 'fetch-test',
|
||||
url: fetchMessage.url,
|
||||
optionsJson: fetchMessage.optionsJson,
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (msg.type === 'fetchResponse') {
|
||||
const fetchResponse = message as { response?: string }
|
||||
setImmediate(() => {
|
||||
proc.emit('message', {
|
||||
type: 'result',
|
||||
executionId: currentExecutionId,
|
||||
result: { result: fetchResponse.response ?? '', stdout: '' },
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
setImmediate(() => proc.emit('message', { type: 'ready' }))
|
||||
return proc
|
||||
}
|
||||
|
||||
const { mockSpawn, mockExecSync, mockSanitizeUrl, mockEnv } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
mockExecSync: vi.fn(() => Buffer.from('v23.11.0')),
|
||||
mockSanitizeUrl: vi.fn((url: string) => url),
|
||||
mockEnv: {
|
||||
IVM_POOL_SIZE: '1',
|
||||
IVM_MAX_CONCURRENT: '100',
|
||||
IVM_MAX_PER_WORKER: '100',
|
||||
IVM_WORKER_IDLE_TIMEOUT_MS: '60000',
|
||||
IVM_MAX_QUEUE_SIZE: '10',
|
||||
IVM_MAX_ACTIVE_PER_OWNER: '100',
|
||||
IVM_MAX_QUEUED_PER_OWNER: '10',
|
||||
IVM_MAX_OWNER_WEIGHT: '5',
|
||||
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '100',
|
||||
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: '1000',
|
||||
IVM_QUEUE_TIMEOUT_MS: '1000',
|
||||
IVM_MAX_FETCH_RESPONSE_BYTES: '',
|
||||
IVM_MAX_FETCH_RESPONSE_CHARS: '',
|
||||
IVM_MAX_FETCH_URL_LENGTH: '',
|
||||
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '',
|
||||
REDIS_URL: '',
|
||||
} as Record<string, string>,
|
||||
}))
|
||||
|
||||
const mockSecureFetch = inputValidationMockFns.mockSecureFetchWithValidation
|
||||
const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
vi.mock('@/lib/core/utils/logging', () => ({
|
||||
sanitizeUrlForLog: mockSanitizeUrl,
|
||||
}))
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: mockEnv,
|
||||
}))
|
||||
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
|
||||
vi.mock('node:child_process', () => ({
|
||||
execSync: mockExecSync,
|
||||
spawn: mockSpawn,
|
||||
}))
|
||||
|
||||
async function loadExecutionModule(options: {
|
||||
envOverrides?: Record<string, string>
|
||||
spawns: SpawnFactory[]
|
||||
redisEvalImpl?: RedisEval
|
||||
secureFetchImpl?: SecureFetchImpl
|
||||
}) {
|
||||
const spawnQueue = [...options.spawns]
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const next = spawnQueue.shift()
|
||||
if (!next) {
|
||||
throw new Error('No mock spawn factory configured')
|
||||
}
|
||||
return next() as ReturnType<typeof mockSpawn>
|
||||
})
|
||||
|
||||
if (options.secureFetchImpl) {
|
||||
mockSecureFetch.mockImplementation(options.secureFetchImpl)
|
||||
} else {
|
||||
mockSecureFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Map<string, string>(),
|
||||
text: async () => '',
|
||||
json: async () => ({}),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}))
|
||||
}
|
||||
|
||||
Object.assign(mockEnv, {
|
||||
IVM_POOL_SIZE: '1',
|
||||
IVM_MAX_CONCURRENT: '100',
|
||||
IVM_MAX_PER_WORKER: '100',
|
||||
IVM_WORKER_IDLE_TIMEOUT_MS: '60000',
|
||||
IVM_MAX_QUEUE_SIZE: '10',
|
||||
IVM_MAX_ACTIVE_PER_OWNER: '100',
|
||||
IVM_MAX_QUEUED_PER_OWNER: '10',
|
||||
IVM_MAX_OWNER_WEIGHT: '5',
|
||||
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '100',
|
||||
IVM_DISTRIBUTED_LEASE_MIN_TTL_MS: '1000',
|
||||
IVM_QUEUE_TIMEOUT_MS: '1000',
|
||||
IVM_MAX_FETCH_RESPONSE_BYTES: '',
|
||||
IVM_MAX_FETCH_RESPONSE_CHARS: '',
|
||||
IVM_MAX_FETCH_URL_LENGTH: '',
|
||||
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '',
|
||||
REDIS_URL: '',
|
||||
...(options.envOverrides ?? {}),
|
||||
})
|
||||
|
||||
const redisEval = options.redisEvalImpl ? vi.fn(options.redisEvalImpl) : undefined
|
||||
mockGetRedisClient.mockImplementation(() =>
|
||||
redisEval
|
||||
? ({
|
||||
eval: redisEval,
|
||||
} as unknown)
|
||||
: null
|
||||
)
|
||||
|
||||
vi.resetModules()
|
||||
|
||||
const mod = await import('@/lib/execution/isolated-vm')
|
||||
return { ...mod, spawnMock: mockSpawn, secureFetchMock: mockSecureFetch }
|
||||
}
|
||||
|
||||
describe('isolated-vm scheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('recovers from an initial spawn failure and drains queued work', async () => {
|
||||
const { executeInIsolatedVM, spawnMock } = await loadExecutionModule({
|
||||
spawns: [createStartupFailureProc, () => createReadyProc('ok')],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "ok"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.result).toBe('ok')
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects new requests when the queue is full', async () => {
|
||||
const holder = createControllableReadyProc()
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_CONCURRENT: '1',
|
||||
IVM_MAX_QUEUE_SIZE: '1',
|
||||
IVM_QUEUE_TIMEOUT_MS: '50',
|
||||
},
|
||||
spawns: [holder.spawn],
|
||||
})
|
||||
|
||||
// Occupy the single global concurrency slot with an active execution. Awaiting
|
||||
// `dispatched` is event-driven, so the queue state below is fully deterministic.
|
||||
const holderPromise = executeInIsolatedVM({
|
||||
code: 'return "holder"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 1000,
|
||||
requestId: 'req-holder',
|
||||
ownerKey: 'user:holder',
|
||||
})
|
||||
await holder.dispatched
|
||||
|
||||
// With the slot held, this request lands in the queue (size 1, now full)...
|
||||
const queuedPromise = executeInIsolatedVM({
|
||||
code: 'return 1',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-2',
|
||||
ownerKey: 'user:a',
|
||||
})
|
||||
|
||||
// ...and this one overflows it and is rejected immediately.
|
||||
const rejected = await executeInIsolatedVM({
|
||||
code: 'return 2',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-3',
|
||||
ownerKey: 'user:b',
|
||||
})
|
||||
|
||||
expect(rejected.error?.message).toContain('at capacity')
|
||||
|
||||
const queued = await queuedPromise
|
||||
expect(queued.error?.message).toContain('timed out waiting')
|
||||
|
||||
holder.release()
|
||||
await holderPromise
|
||||
})
|
||||
|
||||
it('enforces per-owner queued limit', async () => {
|
||||
const holder = createControllableReadyProc()
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_CONCURRENT: '1',
|
||||
IVM_MAX_QUEUED_PER_OWNER: '1',
|
||||
IVM_QUEUE_TIMEOUT_MS: '50',
|
||||
},
|
||||
spawns: [holder.spawn],
|
||||
})
|
||||
|
||||
// Hold the single global slot with one of the owner's executions so the next
|
||||
// requests deterministically queue instead of dispatching.
|
||||
const holderPromise = executeInIsolatedVM({
|
||||
code: 'return "holder"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 1000,
|
||||
requestId: 'req-holder',
|
||||
ownerKey: 'user:hog',
|
||||
})
|
||||
await holder.dispatched
|
||||
|
||||
// First queued request for the owner fills their per-owner queue allowance...
|
||||
const queuedPromise = executeInIsolatedVM({
|
||||
code: 'return 1',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-4',
|
||||
ownerKey: 'user:hog',
|
||||
})
|
||||
|
||||
// ...so the second is rejected for exceeding the per-owner queued limit.
|
||||
const rejected = await executeInIsolatedVM({
|
||||
code: 'return 2',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-5',
|
||||
ownerKey: 'user:hog',
|
||||
})
|
||||
|
||||
expect(rejected.error?.message).toContain('Too many concurrent')
|
||||
|
||||
const queued = await queuedPromise
|
||||
expect(queued.error?.message).toContain('timed out waiting')
|
||||
|
||||
holder.release()
|
||||
await holderPromise
|
||||
})
|
||||
|
||||
it('enforces distributed owner in-flight lease limit when Redis is configured', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER: '1',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
},
|
||||
spawns: [() => createReadyProc('ok')],
|
||||
redisEvalImpl: (...args: unknown[]) => {
|
||||
const script = String(args[0] ?? '')
|
||||
if (script.includes('ZREMRANGEBYSCORE')) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
},
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "blocked"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-6',
|
||||
ownerKey: 'user:distributed',
|
||||
})
|
||||
|
||||
expect(result.error?.message).toContain('Too many concurrent')
|
||||
})
|
||||
|
||||
it('falls back to local execution when Redis is configured but unavailable', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
},
|
||||
spawns: [() => createReadyProc('ok')],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "ok"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-7',
|
||||
ownerKey: 'user:redis-down',
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.result).toBe('ok')
|
||||
})
|
||||
|
||||
it('falls back to local execution when Redis lease evaluation errors', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
},
|
||||
spawns: [() => createReadyProc('ok')],
|
||||
redisEvalImpl: (...args: unknown[]) => {
|
||||
const script = String(args[0] ?? '')
|
||||
if (script.includes('ZREMRANGEBYSCORE')) {
|
||||
throw new Error('redis timeout')
|
||||
}
|
||||
return 1
|
||||
},
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "ok"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-8',
|
||||
ownerKey: 'user:redis-error',
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.result).toBe('ok')
|
||||
})
|
||||
|
||||
it('applies weighted owner scheduling when draining queued executions', async () => {
|
||||
const { executeInIsolatedVM } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_PER_WORKER: '1',
|
||||
},
|
||||
spawns: [() => createReadyProcWithDelay(1)],
|
||||
})
|
||||
|
||||
const completionOrder: string[] = []
|
||||
const pushCompletion = (label: string) => (res: { result: unknown }) => {
|
||||
completionOrder.push(String(res.result ?? label))
|
||||
return res
|
||||
}
|
||||
|
||||
const p1 = executeInIsolatedVM({
|
||||
code: 'return 1',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'a-1',
|
||||
ownerKey: 'user:a',
|
||||
ownerWeight: 2,
|
||||
}).then(pushCompletion('a-1'))
|
||||
|
||||
const p2 = executeInIsolatedVM({
|
||||
code: 'return 2',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'a-2',
|
||||
ownerKey: 'user:a',
|
||||
ownerWeight: 2,
|
||||
}).then(pushCompletion('a-2'))
|
||||
|
||||
const p3 = executeInIsolatedVM({
|
||||
code: 'return 3',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'b-1',
|
||||
ownerKey: 'user:b',
|
||||
ownerWeight: 1,
|
||||
}).then(pushCompletion('b-1'))
|
||||
|
||||
const p4 = executeInIsolatedVM({
|
||||
code: 'return 4',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'b-2',
|
||||
ownerKey: 'user:b',
|
||||
ownerWeight: 1,
|
||||
}).then(pushCompletion('b-2'))
|
||||
|
||||
const p5 = executeInIsolatedVM({
|
||||
code: 'return 5',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 500,
|
||||
requestId: 'a-3',
|
||||
ownerKey: 'user:a',
|
||||
ownerWeight: 2,
|
||||
}).then(pushCompletion('a-3'))
|
||||
|
||||
await Promise.all([p1, p2, p3, p4, p5])
|
||||
|
||||
expect(completionOrder.slice(0, 3)).toEqual(['a-1', 'a-2', 'a-3'])
|
||||
expect(completionOrder).toEqual(['a-1', 'a-2', 'a-3', 'b-1', 'b-2'])
|
||||
})
|
||||
|
||||
it('rejects oversized fetch options payloads before outbound call', async () => {
|
||||
const { executeInIsolatedVM, secureFetchMock } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '50',
|
||||
},
|
||||
spawns: [
|
||||
() =>
|
||||
createReadyFetchProxyProc({
|
||||
url: 'https://example.com',
|
||||
optionsJson: 'x'.repeat(100),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "fetch-options"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-fetch-options',
|
||||
})
|
||||
|
||||
const payload = JSON.parse(String(result.result))
|
||||
expect(payload.error).toContain('Fetch options exceed maximum payload size')
|
||||
expect(secureFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects overly long fetch URLs before outbound call', async () => {
|
||||
const { executeInIsolatedVM, secureFetchMock } = await loadExecutionModule({
|
||||
envOverrides: {
|
||||
IVM_MAX_FETCH_URL_LENGTH: '30',
|
||||
},
|
||||
spawns: [
|
||||
() =>
|
||||
createReadyFetchProxyProc({
|
||||
url: 'https://example.com/path/to/a/very/long/resource',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeInIsolatedVM({
|
||||
code: 'return "fetch-url"',
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: 100,
|
||||
requestId: 'req-fetch-url',
|
||||
})
|
||||
|
||||
const payload = JSON.parse(String(result.result))
|
||||
expect(payload.error).toContain('fetch URL exceeds maximum length')
|
||||
expect(secureFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Supported code execution languages
|
||||
*/
|
||||
export enum CodeLanguage {
|
||||
JavaScript = 'javascript',
|
||||
Python = 'python',
|
||||
Shell = 'shell',
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid CodeLanguage
|
||||
*/
|
||||
export function isValidCodeLanguage(value: string): value is CodeLanguage {
|
||||
return Object.values(CodeLanguage).includes(value as CodeLanguage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language display name
|
||||
*/
|
||||
export function getLanguageDisplayName(language: CodeLanguage): string {
|
||||
switch (language) {
|
||||
case CodeLanguage.JavaScript:
|
||||
return 'JavaScript'
|
||||
case CodeLanguage.Python:
|
||||
return 'Python'
|
||||
case CodeLanguage.Shell:
|
||||
return 'Shell'
|
||||
default:
|
||||
return language
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default language for code execution
|
||||
*/
|
||||
export const DEFAULT_CODE_LANGUAGE = CodeLanguage.JavaScript
|
||||
@@ -0,0 +1,19 @@
|
||||
const activeExecutionAborters = new Map<string, () => void>()
|
||||
|
||||
export function registerManualExecutionAborter(executionId: string, abort: () => void): void {
|
||||
activeExecutionAborters.set(executionId, abort)
|
||||
}
|
||||
|
||||
export function unregisterManualExecutionAborter(executionId: string): void {
|
||||
activeExecutionAborters.delete(executionId)
|
||||
}
|
||||
|
||||
export function abortManualExecution(executionId: string): boolean {
|
||||
const abort = activeExecutionAborters.get(executionId)
|
||||
if (!abort) {
|
||||
return false
|
||||
}
|
||||
|
||||
abort()
|
||||
return true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { loggingSessionMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetWorkspaceBilledAccountUserId, mockCheckRateLimit, mockGetActivelyBannedUserIds } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetWorkspaceBilledAccountUserId: vi.fn(),
|
||||
mockCheckRateLimit: vi.fn(),
|
||||
mockGetActivelyBannedUserIds: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: {} }))
|
||||
vi.mock('drizzle-orm', () => ({ eq: vi.fn() }))
|
||||
vi.mock('@/lib/auth/ban', () => ({
|
||||
getActivelyBannedUserIds: mockGetActivelyBannedUserIds,
|
||||
}))
|
||||
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
|
||||
checkServerSideUsageLimits: vi.fn(),
|
||||
checkOrgMemberUsageLimit: vi.fn().mockResolvedValue({ isExceeded: false }),
|
||||
}))
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/execution-limits', () => ({
|
||||
getExecutionTimeout: vi.fn(() => 0),
|
||||
}))
|
||||
vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({
|
||||
// Regular function (not an arrow) so `new RateLimiter()` is constructable under
|
||||
// vitest 4.x, which rejects `new` on an arrow-implemented mock.
|
||||
RateLimiter: vi.fn(function (this: unknown) {
|
||||
return { checkRateLimitWithSubscription: mockCheckRateLimit }
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
|
||||
vi.mock('@/lib/workspaces/utils', () => ({
|
||||
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workflow', () => ({
|
||||
getActiveWorkflowRecord: vi.fn().mockResolvedValue({
|
||||
id: 'workflow-1',
|
||||
userId: 'creator-1',
|
||||
workspaceId: 'workspace-1',
|
||||
isDeployed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { preprocessExecution } from './preprocessing'
|
||||
|
||||
describe('preprocessExecution correlation logging', () => {
|
||||
it('preserves trigger correlation when logging preprocessing failures', async () => {
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValueOnce(null)
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const correlation = {
|
||||
executionId: 'execution-1',
|
||||
requestId: 'request-1',
|
||||
source: 'schedule' as const,
|
||||
workflowId: 'workflow-1',
|
||||
scheduleId: 'schedule-1',
|
||||
triggerType: 'schedule',
|
||||
scheduledFor: '2025-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'unknown',
|
||||
triggerType: 'schedule',
|
||||
executionId: 'execution-1',
|
||||
requestId: 'request-1',
|
||||
loggingSession: loggingSession as any,
|
||||
triggerData: { correlation },
|
||||
workflowRecord: {
|
||||
id: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
isDeployed: true,
|
||||
} as any,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(loggingSession.safeStart).toHaveBeenCalledWith({
|
||||
userId: 'unknown',
|
||||
workspaceId: 'workspace-1',
|
||||
variables: {},
|
||||
triggerData: { correlation },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('preprocessExecution logPreprocessingErrors option', () => {
|
||||
const baseOptions = {
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
triggerType: 'workflow' as const,
|
||||
executionId: 'execution-1',
|
||||
requestId: 'request-1',
|
||||
checkDeployment: false,
|
||||
checkRateLimit: true,
|
||||
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: false } as any,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1')
|
||||
vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any)
|
||||
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
|
||||
isExceeded: false,
|
||||
currentUsage: 1,
|
||||
limit: 10,
|
||||
} as any)
|
||||
mockCheckRateLimit.mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 100,
|
||||
resetAt: new Date(),
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses preprocessing-error logging when logPreprocessingErrors is false', async () => {
|
||||
vi.mocked(checkServerSideUsageLimits).mockResolvedValueOnce({
|
||||
isExceeded: true,
|
||||
currentUsage: 20,
|
||||
limit: 10,
|
||||
message: 'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
} as any)
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
logPreprocessingErrors: false,
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: { statusCode: 402 } })
|
||||
// No execution-log row written — the caller (table cell) surfaces it instead.
|
||||
expect(loggingSession.safeStart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preprocessExecution ban gate', () => {
|
||||
const baseOptions = {
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
triggerType: 'workflow' as const,
|
||||
executionId: 'execution-1',
|
||||
requestId: 'request-1',
|
||||
checkDeployment: false,
|
||||
checkRateLimit: false,
|
||||
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: true } as any,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1')
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue([])
|
||||
vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any)
|
||||
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
|
||||
isExceeded: false,
|
||||
currentUsage: 1,
|
||||
limit: 10,
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('blocks execution with 403 when the actor is banned (ban wins over the parallel gates)', async () => {
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { statusCode: 403, logCreated: true, message: 'Account suspended' },
|
||||
})
|
||||
expect(loggingSession.safeStart).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 (ban precedence) when ban, usage, and rate limit all fail simultaneously', async () => {
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
|
||||
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
|
||||
isExceeded: true,
|
||||
currentUsage: 20,
|
||||
limit: 10,
|
||||
message: 'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
} as any)
|
||||
mockCheckRateLimit.mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(),
|
||||
})
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
checkRateLimit: true,
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
// Ban (403) takes precedence over usage (402) and rate limit (429),
|
||||
// independent of which parallel gate's promise settled first.
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { statusCode: 403, logCreated: true, message: 'Account suspended' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not debit rate-limit quota when the ban gate rejects', async () => {
|
||||
// The rate-limit gate consumes a token, so it must not run for a request
|
||||
// an earlier gate (ban) already rejects.
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
|
||||
|
||||
const result = await preprocessExecution({ ...baseOptions, checkRateLimit: true })
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: { statusCode: 403 } })
|
||||
expect(mockCheckRateLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not debit rate-limit quota when the usage gate rejects', async () => {
|
||||
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
|
||||
isExceeded: true,
|
||||
currentUsage: 20,
|
||||
limit: 10,
|
||||
message: 'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
} as any)
|
||||
|
||||
const result = await preprocessExecution({ ...baseOptions, checkRateLimit: true })
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: { statusCode: 402 } })
|
||||
expect(mockCheckRateLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consumes the rate-limit gate exactly once when the ban and usage gates pass', async () => {
|
||||
mockCheckRateLimit.mockResolvedValue({ allowed: true, remaining: 5, resetAt: new Date() })
|
||||
|
||||
// skipConcurrencyReservation bypasses the STEP 7 admission reservation so the
|
||||
// assertion isolates the rate gate and does not depend on Redis availability.
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
checkRateLimit: true,
|
||||
skipConcurrencyReservation: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockCheckRateLimit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('checks the billing actor, caller-provided userId, and workflow owner in one call', async () => {
|
||||
const result = await preprocessExecution(baseOptions)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith([
|
||||
'billed-account-1',
|
||||
'owner-1',
|
||||
'creator-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes the "unknown" sentinel userId but still checks the workflow owner', async () => {
|
||||
const result = await preprocessExecution({ ...baseOptions, userId: 'unknown' })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'creator-1'])
|
||||
})
|
||||
|
||||
it('fails closed with 500 when the ban check errors', async () => {
|
||||
mockGetActivelyBannedUserIds.mockRejectedValue(new Error('db down'))
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: { statusCode: 500, logCreated: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('preprocessExecution resolvedActorUserId reuse', () => {
|
||||
const baseOptions = {
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
triggerType: 'webhook' as const,
|
||||
executionId: 'execution-1',
|
||||
requestId: 'request-1',
|
||||
checkDeployment: false,
|
||||
checkRateLimit: false,
|
||||
skipConcurrencyReservation: true,
|
||||
workspaceId: 'workspace-1',
|
||||
workflowRecord: { id: 'workflow-1', workspaceId: 'workspace-1', isDeployed: true } as any,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-account-1')
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue([])
|
||||
vi.mocked(getHighestPrioritySubscription).mockResolvedValue({ plan: 'free' } as any)
|
||||
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
|
||||
isExceeded: false,
|
||||
currentUsage: 1,
|
||||
limit: 10,
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('skips the workspace billed-account lookup when an actor is pre-resolved', async () => {
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
resolvedActorUserId: 'pre-resolved-actor',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.actorUserId).toBe('pre-resolved-actor')
|
||||
expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still runs the ban gate against the pre-resolved actor', async () => {
|
||||
mockGetActivelyBannedUserIds.mockResolvedValue(['pre-resolved-actor'])
|
||||
|
||||
const result = await preprocessExecution({
|
||||
...baseOptions,
|
||||
resolvedActorUserId: 'pre-resolved-actor',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: false, error: { statusCode: 403 } })
|
||||
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['pre-resolved-actor'])
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the billed-account lookup when no actor is pre-resolved', async () => {
|
||||
const result = await preprocessExecution(baseOptions)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.actorUserId).toBe('billed-account-1')
|
||||
expect(mockGetWorkspaceBilledAccountUserId).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,821 @@
|
||||
import type { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
|
||||
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
|
||||
import {
|
||||
checkOrgMemberUsageLimit,
|
||||
checkServerSideUsageLimits,
|
||||
} from '@/lib/billing/calculations/usage-monitor'
|
||||
import { reserveExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
|
||||
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import {
|
||||
describeRetryableInfrastructureError,
|
||||
isRetryableInfrastructureError,
|
||||
} from '@/lib/core/errors/retryable-infrastructure'
|
||||
import { getExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
|
||||
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
|
||||
import { LoggingSession, type SessionStartParams } from '@/lib/logs/execution/logging-session'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import type { CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
const logger = createLogger('ExecutionPreprocessing')
|
||||
|
||||
const BILLING_ERROR_MESSAGES = {
|
||||
BILLING_REQUIRED:
|
||||
'Unable to resolve billing account. This workflow cannot execute without a valid billing account.',
|
||||
BILLING_ERROR_GENERIC: 'Error resolving billing account',
|
||||
} as const
|
||||
|
||||
export interface PreprocessExecutionOptions {
|
||||
// Required fields
|
||||
workflowId: string
|
||||
userId: string // The authenticated user ID
|
||||
triggerType: CoreTriggerType
|
||||
executionId: string
|
||||
requestId: string
|
||||
|
||||
// Optional checks configuration
|
||||
checkRateLimit?: boolean // Default: false for manual/chat, true for others
|
||||
checkDeployment?: boolean // Default: true for non-manual triggers
|
||||
skipUsageLimits?: boolean // Default: false (only use for test mode)
|
||||
/**
|
||||
* Skip the atomic in-flight concurrency reservation while still enforcing the
|
||||
* usage-cost cap. Default: false. Set by surfaces that already bound and pace
|
||||
* their own fan-out (e.g. table-cell dispatch, which is row-bounded, async
|
||||
* rate-limited, and surfaces a graceful "wait/upgrade" state) so the
|
||||
* reservation's 429 can't surface as a hard error there.
|
||||
*/
|
||||
skipConcurrencyReservation?: boolean
|
||||
logPreprocessingErrors?: boolean // Default: true. When false, skip writing workflow_execution_logs error rows (caller surfaces failures itself, e.g. table cells)
|
||||
|
||||
// Context information
|
||||
workspaceId?: string // If known, used for billing resolution
|
||||
loggingSession?: LoggingSession // If provided, will be used for error logging
|
||||
triggerData?: SessionStartParams['triggerData']
|
||||
isResumeContext?: boolean // Deprecated: no billing fallback is allowed
|
||||
useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys)
|
||||
/** @deprecated No longer used - background/async executions always use deployed state */
|
||||
useDraftState?: boolean
|
||||
/** Pre-fetched workflow row for caller context; preprocessing still re-checks active state. */
|
||||
workflowRecord?: WorkflowRecord
|
||||
/**
|
||||
* Billing actor already resolved by an upstream gate earlier in the same
|
||||
* request (e.g. the webhook route's preprocessing pass, whose result is carried
|
||||
* as the job's userId). When provided, the redundant workspace billed-account
|
||||
* lookup is skipped. The ban, deployment, usage, and rate-limit gates still run
|
||||
* against this actor — only the resolution is reused, never a gate.
|
||||
*/
|
||||
resolvedActorUserId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of preprocessing checks
|
||||
*/
|
||||
export interface PreprocessExecutionResult {
|
||||
success: boolean
|
||||
error?: {
|
||||
message: string
|
||||
statusCode: number
|
||||
logCreated: boolean
|
||||
retryable?: boolean
|
||||
cause?: Record<string, unknown>
|
||||
}
|
||||
actorUserId?: string
|
||||
workflowRecord?: WorkflowRecord
|
||||
userSubscription?: SubscriptionInfo | null
|
||||
rateLimitInfo?: {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
resetAt: Date
|
||||
}
|
||||
executionTimeout?: {
|
||||
sync: number
|
||||
async: number
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowRecord = typeof workflow.$inferSelect
|
||||
type SubscriptionInfo = HighestPrioritySubscription
|
||||
|
||||
export async function preprocessExecution(
|
||||
options: PreprocessExecutionOptions
|
||||
): Promise<PreprocessExecutionResult> {
|
||||
const {
|
||||
workflowId,
|
||||
userId,
|
||||
triggerType,
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat',
|
||||
checkDeployment = triggerType !== 'manual',
|
||||
skipUsageLimits = false,
|
||||
skipConcurrencyReservation = false,
|
||||
logPreprocessingErrors = true,
|
||||
workspaceId: providedWorkspaceId,
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
isResumeContext: _isResumeContext = false,
|
||||
useAuthenticatedUserAsActor = false,
|
||||
workflowRecord: prefetchedWorkflowRecord,
|
||||
resolvedActorUserId,
|
||||
} = options
|
||||
|
||||
// When `logPreprocessingErrors` is false the caller surfaces failures itself
|
||||
// (e.g. table cells use cell state / SSE), so skip the execution-log writes.
|
||||
const recordPreprocessingError: typeof logPreprocessingError = (args) =>
|
||||
logPreprocessingErrors ? logPreprocessingError(args) : Promise.resolve()
|
||||
|
||||
logger.info(`[${requestId}] Starting execution preprocessing`, {
|
||||
workflowId,
|
||||
userId,
|
||||
triggerType,
|
||||
executionId,
|
||||
})
|
||||
|
||||
// ========== STEP 1: Validate Workflow Exists ==========
|
||||
if (prefetchedWorkflowRecord && prefetchedWorkflowRecord.id !== workflowId) {
|
||||
logger.error(`[${requestId}] Prefetched workflow record ID mismatch`, {
|
||||
expected: workflowId,
|
||||
received: prefetchedWorkflowRecord.id,
|
||||
})
|
||||
throw new Error(
|
||||
`Prefetched workflow record ID mismatch: expected ${workflowId}, got ${prefetchedWorkflowRecord.id}`
|
||||
)
|
||||
}
|
||||
let workflowRecord: WorkflowRecord | null = prefetchedWorkflowRecord ?? null
|
||||
if (!workflowRecord) {
|
||||
try {
|
||||
workflowRecord = await getActiveWorkflowRecord(workflowId)
|
||||
|
||||
if (!workflowRecord) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
|
||||
await recordPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: 'unknown',
|
||||
workspaceId: '',
|
||||
errorMessage:
|
||||
'Workflow not found. The workflow may have been deleted or is no longer accessible.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Workflow not found',
|
||||
statusCode: 404,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching workflow`, { error, workflowId })
|
||||
|
||||
await recordPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: userId || 'unknown',
|
||||
workspaceId: providedWorkspaceId || '',
|
||||
errorMessage: 'Internal error while fetching workflow',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Internal error while fetching workflow',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
retryable: isRetryableInfrastructureError(error),
|
||||
cause: describeRetryableInfrastructureError(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if (workflowRecord.archivedAt) {
|
||||
logger.warn(`[${requestId}] Prefetched workflow is archived: ${workflowId}`)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Workflow not found',
|
||||
statusCode: 404,
|
||||
logCreated: false,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const activeWorkflow = await getActiveWorkflowRecord(workflowId)
|
||||
if (!activeWorkflow) {
|
||||
logger.warn(`[${requestId}] Workflow archived before execution started: ${workflowId}`)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Workflow not found',
|
||||
statusCode: 404,
|
||||
logCreated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
workflowRecord = activeWorkflow
|
||||
}
|
||||
|
||||
const workspaceId = workflowRecord.workspaceId || providedWorkspaceId || ''
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} has no workspaceId; execution blocked`)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message:
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot execute.',
|
||||
statusCode: 403,
|
||||
logCreated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 2: Check Deployment Status ==========
|
||||
// If workflow is not deployed and deployment is required, reject without logging.
|
||||
// No log entry or cost should be created for calls to undeployed workflows
|
||||
// since the workflow was never intended to run.
|
||||
if (checkDeployment && !workflowRecord.isDeployed) {
|
||||
logger.warn(`[${requestId}] Workflow not deployed: ${workflowId}`)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Workflow is not deployed',
|
||||
statusCode: 403,
|
||||
logCreated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 3: Resolve Billing Actor ==========
|
||||
let actorUserId: string | null = null
|
||||
|
||||
try {
|
||||
// For client-side executions and personal API keys, the authenticated
|
||||
// user is the billing and permission actor — not the workspace owner.
|
||||
if (useAuthenticatedUserAsActor && userId) {
|
||||
actorUserId = userId
|
||||
logger.info(`[${requestId}] Using authenticated user as actor: ${actorUserId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse an actor already resolved upstream this request (e.g. the webhook
|
||||
* route's preprocessing) to skip the redundant workspace billed-account
|
||||
* lookup. Gates below still run against this actor.
|
||||
*/
|
||||
if (!actorUserId && resolvedActorUserId) {
|
||||
actorUserId = resolvedActorUserId
|
||||
logger.info(`[${requestId}] Using pre-resolved billing actor: ${actorUserId}`)
|
||||
}
|
||||
|
||||
if (!actorUserId && workspaceId) {
|
||||
actorUserId = await getWorkspaceBilledAccountUserId(workspaceId)
|
||||
if (actorUserId) {
|
||||
logger.info(`[${requestId}] Using workspace billed account: ${actorUserId}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!actorUserId) {
|
||||
const fallbackUserId = userId || 'unknown'
|
||||
logger.warn(`[${requestId}] ${BILLING_ERROR_MESSAGES.BILLING_REQUIRED}`, {
|
||||
workflowId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
await recordPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage: BILLING_ERROR_MESSAGES.BILLING_REQUIRED,
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Unable to resolve billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
|
||||
const fallbackUserId = userId || 'unknown'
|
||||
await recordPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC,
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Error resolving billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
retryable: isRetryableInfrastructureError(error),
|
||||
cause: describeRetryableInfrastructureError(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEPS 3.5–6: Preflight Gates ==========
|
||||
// Read-only gates (ban, subscription, usage) run concurrently; the stateful
|
||||
// rate-limit gate runs after they pass. Precedence: ban 403 → usage 402 → rate 429.
|
||||
|
||||
/**
|
||||
* A failing gate's deferred outcome: the response to return, plus an optional
|
||||
* error-log write to flush before returning. Evaluated in precedence order.
|
||||
*/
|
||||
interface GateFailure {
|
||||
response: PreprocessExecutionResult
|
||||
recordError?: Parameters<typeof recordPreprocessingError>[0]
|
||||
}
|
||||
|
||||
/** Usage figures captured by STEP 5 and reused by the STEP 7 reservation. */
|
||||
interface UsageSnapshot {
|
||||
currentUsage: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
const banCheck = (async (): Promise<GateFailure | null> => {
|
||||
// Blocks executions when the billing actor, the workflow owner, or the
|
||||
// caller-provided userId (chat deployer, authenticated caller) has an
|
||||
// active ban or a blocked email domain. The owner comes from the workflow
|
||||
// record so schedules — which pass the 'unknown' sentinel — are covered.
|
||||
const banCandidateIds = [actorUserId]
|
||||
if (userId && userId !== 'unknown' && userId !== actorUserId) {
|
||||
banCandidateIds.push(userId)
|
||||
}
|
||||
if (workflowRecord.userId && !banCandidateIds.includes(workflowRecord.userId)) {
|
||||
banCandidateIds.push(workflowRecord.userId)
|
||||
}
|
||||
try {
|
||||
const bannedUserIds = await getActivelyBannedUserIds(banCandidateIds)
|
||||
if (bannedUserIds.length > 0) {
|
||||
logger.warn(`[${requestId}] Execution blocked: banned account`, {
|
||||
workflowId,
|
||||
bannedUserIds,
|
||||
triggerType,
|
||||
})
|
||||
|
||||
return {
|
||||
response: {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Account suspended',
|
||||
statusCode: 403,
|
||||
logCreated: true,
|
||||
},
|
||||
},
|
||||
recordError: {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: 'This account has been suspended. Workflow executions are blocked.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error checking account ban status`, { error, actorUserId })
|
||||
|
||||
return {
|
||||
response: {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Unable to verify account status. Execution blocked for security.',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
retryable: isRetryableInfrastructureError(error),
|
||||
cause: describeRetryableInfrastructureError(error),
|
||||
},
|
||||
},
|
||||
recordError: {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: 'Unable to verify account status. Execution blocked for security.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
},
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
// ========== STEP 4: Get Subscription ==========
|
||||
const subscriptionFetch = getHighestPrioritySubscription(actorUserId)
|
||||
|
||||
const [banFailure, userSubscription] = await Promise.all([banCheck, subscriptionFetch])
|
||||
|
||||
/**
|
||||
* STEP 5: usage + per-member org usage gate. Returns the failure outcome (or
|
||||
* `null` on pass/skip) plus the usage snapshot reused by the STEP 7 admission
|
||||
* reservation. The snapshot is returned rather than written to an outer
|
||||
* variable so concurrent gate tasks share no mutable state.
|
||||
*/
|
||||
const usageCheckTask = (async (): Promise<{
|
||||
failure: GateFailure | null
|
||||
snapshot: UsageSnapshot | null
|
||||
}> => {
|
||||
if (skipUsageLimits) return { failure: null, snapshot: null }
|
||||
let snapshot: UsageSnapshot | null = null
|
||||
try {
|
||||
const usageCheck = await checkServerSideUsageLimits(actorUserId, userSubscription)
|
||||
snapshot = { currentUsage: usageCheck.currentUsage, limit: usageCheck.limit }
|
||||
if (usageCheck.isExceeded) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${actorUserId} has exceeded usage limits. Blocking execution.`,
|
||||
{
|
||||
currentUsage: usageCheck.currentUsage,
|
||||
limit: usageCheck.limit,
|
||||
workflowId,
|
||||
triggerType,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
failure: {
|
||||
response: {
|
||||
success: false,
|
||||
error: {
|
||||
message:
|
||||
usageCheck.message ||
|
||||
'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
statusCode: 402,
|
||||
logCreated: true,
|
||||
},
|
||||
},
|
||||
recordError: {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage:
|
||||
usageCheck.message ||
|
||||
`Usage limit exceeded: $${usageCheck.currentUsage?.toFixed(2)} used of $${usageCheck.limit?.toFixed(2)} limit. Please upgrade your plan to continue.`,
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
},
|
||||
},
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
// Per-member org-workspace cap (hosted-only). Independent, additive gate:
|
||||
// blocks an individual member's executions in org-owned workspaces once
|
||||
// their personal credit limit for the org is reached, even if the pooled
|
||||
// org limit still has room.
|
||||
const memberUsageCheck = await checkOrgMemberUsageLimit(actorUserId, workspaceId)
|
||||
if (memberUsageCheck.isExceeded) {
|
||||
const memberLimitMessage =
|
||||
memberUsageCheck.message ||
|
||||
'Member usage limit exceeded for this organization. Ask an organization admin to raise your credit limit to continue.'
|
||||
|
||||
logger.warn(
|
||||
`[${requestId}] User ${actorUserId} exceeded their per-member org usage limit. Blocking execution.`,
|
||||
{
|
||||
currentUsage: memberUsageCheck.currentUsage,
|
||||
limit: memberUsageCheck.limit,
|
||||
workflowId,
|
||||
triggerType,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
failure: {
|
||||
response: {
|
||||
success: false,
|
||||
error: {
|
||||
message: memberLimitMessage,
|
||||
statusCode: 402,
|
||||
logCreated: true,
|
||||
},
|
||||
},
|
||||
recordError: {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: memberLimitMessage,
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
},
|
||||
},
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
return { failure: null, snapshot }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error checking usage limits`, {
|
||||
error,
|
||||
actorUserId,
|
||||
})
|
||||
|
||||
return {
|
||||
failure: {
|
||||
response: {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Unable to determine usage limits. Execution blocked for security.',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
retryable: isRetryableInfrastructureError(error),
|
||||
cause: describeRetryableInfrastructureError(error),
|
||||
},
|
||||
},
|
||||
recordError: {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage:
|
||||
'Unable to determine usage limits. Execution blocked for security. Please contact support.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
},
|
||||
},
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
// ========== STEP 6: Check Rate Limits ==========
|
||||
let rateLimitInfo: { allowed: boolean; remaining: number; resetAt: Date } | undefined
|
||||
|
||||
/**
|
||||
* STEP 6: rate-limit gate. Unlike the other gates this one is NOT read-only —
|
||||
* `checkRateLimitWithSubscription` consumes a token — so it is invoked
|
||||
* sequentially only after the ban and usage gates pass, matching the original
|
||||
* order. Running it eagerly or in parallel would debit rate-limit quota for
|
||||
* requests that ban or usage rejects. Returns the failure outcome, or `null`
|
||||
* on pass/skip; on a non-error outcome it populates `rateLimitInfo`.
|
||||
*/
|
||||
const runRateLimitGate = async (): Promise<GateFailure | null> => {
|
||||
if (!checkRateLimit) return null
|
||||
try {
|
||||
const rateLimiter = new RateLimiter()
|
||||
const info = await rateLimiter.checkRateLimitWithSubscription(
|
||||
actorUserId,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false // not async
|
||||
)
|
||||
rateLimitInfo = info
|
||||
|
||||
if (!info.allowed) {
|
||||
logger.warn(`[${requestId}] Rate limit exceeded for user ${actorUserId}`, {
|
||||
triggerType,
|
||||
remaining: info.remaining,
|
||||
resetAt: info.resetAt,
|
||||
})
|
||||
|
||||
return {
|
||||
response: {
|
||||
success: false,
|
||||
error: {
|
||||
message: `Rate limit exceeded. Please try again later.`,
|
||||
statusCode: 429,
|
||||
logCreated: true,
|
||||
},
|
||||
},
|
||||
recordError: {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: `Rate limit exceeded. ${info.remaining} requests remaining. Resets at ${info.resetAt.toISOString()}.`,
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error checking rate limits`, { error, actorUserId })
|
||||
|
||||
return {
|
||||
response: {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Error checking rate limits',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
retryable: isRetryableInfrastructureError(error),
|
||||
cause: describeRetryableInfrastructureError(error),
|
||||
},
|
||||
},
|
||||
recordError: {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: 'Error checking rate limits. Execution blocked for safety.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usageResult = await usageCheckTask
|
||||
const usageSnapshot = usageResult.snapshot
|
||||
|
||||
const readGateFailure = banFailure ?? usageResult.failure
|
||||
if (readGateFailure) {
|
||||
if (readGateFailure.recordError) {
|
||||
await recordPreprocessingError(readGateFailure.recordError)
|
||||
}
|
||||
return readGateFailure.response
|
||||
}
|
||||
|
||||
const rateLimitFailure = await runRateLimitGate()
|
||||
if (rateLimitFailure) {
|
||||
if (rateLimitFailure.recordError) {
|
||||
await recordPreprocessingError(rateLimitFailure.recordError)
|
||||
}
|
||||
return rateLimitFailure.response
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 7: Atomic admission reservation. Cost is only recorded once an
|
||||
* execution finishes, so without this a burst of concurrent executions all
|
||||
* observe the same pre-burst usage and all pass the gate above. Reserving
|
||||
* bounds in-flight (un-costed) executions per billing entity. Done last so an
|
||||
* earlier rejection never leaves a slot held; the slot is released at
|
||||
* execution completion (see {@link LoggingSession}).
|
||||
*/
|
||||
if (!skipUsageLimits && !skipConcurrencyReservation && usageSnapshot) {
|
||||
try {
|
||||
const { reserved } = await reserveExecutionSlot({
|
||||
userId: actorUserId,
|
||||
executionId,
|
||||
subscription: userSubscription,
|
||||
currentUsage: usageSnapshot.currentUsage,
|
||||
limit: usageSnapshot.limit,
|
||||
})
|
||||
|
||||
if (!reserved) {
|
||||
logger.warn(`[${requestId}] Admission reservation full for user ${actorUserId}`, {
|
||||
workflowId,
|
||||
triggerType,
|
||||
})
|
||||
|
||||
await recordPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage:
|
||||
'Too many concurrent executions in flight for this account. Please wait for in-progress runs to finish and try again.',
|
||||
loggingSession: providedLoggingSession,
|
||||
triggerData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message:
|
||||
'Too many concurrent executions in flight. Please wait for in-progress runs to finish and try again.',
|
||||
statusCode: 429,
|
||||
logCreated: true,
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Unexpected error reserving admission slot`, {
|
||||
error,
|
||||
actorUserId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SUCCESS: All Checks Passed ==========
|
||||
logger.info(`[${requestId}] All preprocessing checks passed`, {
|
||||
workflowId,
|
||||
actorUserId,
|
||||
triggerType,
|
||||
})
|
||||
|
||||
const plan = userSubscription?.plan as SubscriptionPlan | undefined
|
||||
return {
|
||||
success: true,
|
||||
actorUserId,
|
||||
workflowRecord,
|
||||
userSubscription,
|
||||
rateLimitInfo,
|
||||
executionTimeout: {
|
||||
sync: getExecutionTimeout(plan, 'sync'),
|
||||
async: getExecutionTimeout(plan, 'async'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to log preprocessing errors to the database
|
||||
*
|
||||
* This ensures users can see why their workflow execution was blocked.
|
||||
*/
|
||||
async function logPreprocessingError(params: {
|
||||
workflowId: string
|
||||
executionId: string
|
||||
triggerType: string
|
||||
requestId: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
errorMessage: string
|
||||
loggingSession?: LoggingSession
|
||||
triggerData?: SessionStartParams['triggerData']
|
||||
}): Promise<void> {
|
||||
const {
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId,
|
||||
workspaceId,
|
||||
errorMessage,
|
||||
loggingSession,
|
||||
triggerData,
|
||||
} = params
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Cannot log preprocessing error: no workspaceId available`, {
|
||||
workflowId,
|
||||
executionId,
|
||||
errorMessage,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const session =
|
||||
loggingSession || new LoggingSession(workflowId, executionId, triggerType, requestId)
|
||||
|
||||
await session.safeStart({
|
||||
userId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
triggerData,
|
||||
})
|
||||
|
||||
await session.safeCompleteWithError({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
stackTrace: undefined,
|
||||
},
|
||||
traceSpans: [],
|
||||
skipCost: true, // Preprocessing errors should not charge - no execution occurred
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to log preprocessing error`, {
|
||||
error,
|
||||
workflowId,
|
||||
executionId,
|
||||
})
|
||||
// Don't throw - error logging should not block the error response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { loggingSessionMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({
|
||||
mockGetWorkspaceBilledAccountUserId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: {} }))
|
||||
vi.mock('drizzle-orm', () => ({ eq: vi.fn() }))
|
||||
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
|
||||
checkServerSideUsageLimits: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/execution-limits', () => ({
|
||||
getExecutionTimeout: vi.fn(() => 0),
|
||||
}))
|
||||
vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({
|
||||
RateLimiter: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
|
||||
vi.mock('@/lib/workspaces/utils', () => ({
|
||||
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/platform-authz/workflow', () => ({
|
||||
getActiveWorkflowRecord: vi.fn().mockResolvedValue({
|
||||
id: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
isDeployed: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { preprocessExecution } from './preprocessing'
|
||||
|
||||
describe('preprocessExecution webhook correlation logging', () => {
|
||||
it('preserves webhook correlation when logging preprocessing failures', async () => {
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValueOnce(null)
|
||||
|
||||
const loggingSession = {
|
||||
safeStart: vi.fn().mockResolvedValue(true),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const correlation = {
|
||||
executionId: 'execution-webhook-1',
|
||||
requestId: 'request-webhook-1',
|
||||
source: 'webhook' as const,
|
||||
workflowId: 'workflow-1',
|
||||
webhookId: 'webhook-1',
|
||||
path: 'incoming/slack',
|
||||
provider: 'slack',
|
||||
triggerType: 'webhook',
|
||||
}
|
||||
|
||||
const result = await preprocessExecution({
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'unknown',
|
||||
triggerType: 'webhook',
|
||||
executionId: 'execution-webhook-1',
|
||||
requestId: 'request-webhook-1',
|
||||
loggingSession: loggingSession as any,
|
||||
triggerData: { correlation },
|
||||
workflowRecord: {
|
||||
id: 'workflow-1',
|
||||
workspaceId: 'workspace-1',
|
||||
isDeployed: true,
|
||||
} as any,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(loggingSession.safeStart).toHaveBeenCalledWith({
|
||||
userId: 'unknown',
|
||||
workspaceId: 'workspace-1',
|
||||
variables: {},
|
||||
triggerData: { correlation },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createLogger, type Logger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import type { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors'
|
||||
|
||||
type RedisClient = NonNullable<ReturnType<typeof getRedisClient>>
|
||||
|
||||
const logger = createLogger('ExecutionRedisBudget')
|
||||
const REDIS_BUDGET_PREFIX = 'execution:redis-budget:'
|
||||
const MAX_SINGLE_REDIS_WRITE_BYTES = 8 * 1024 * 1024
|
||||
const MAX_EXECUTION_REDIS_BYTES = 64 * 1024 * 1024
|
||||
const MAX_USER_REDIS_BYTES = 256 * 1024 * 1024
|
||||
const REDIS_BUDGET_TTL_SECONDS = 60 * 60
|
||||
|
||||
const RESERVE_REDIS_BYTES_SCRIPT = `
|
||||
local bytes = tonumber(ARGV[1])
|
||||
local execution_limit = tonumber(ARGV[2])
|
||||
local user_limit = tonumber(ARGV[3])
|
||||
local ttl_seconds = tonumber(ARGV[4])
|
||||
local execution_current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if execution_limit > 0 and execution_current + bytes > execution_limit then
|
||||
return {0, 'execution_redis_bytes', execution_current}
|
||||
end
|
||||
local user_current = 0
|
||||
if #KEYS >= 2 then
|
||||
user_current = tonumber(redis.call('GET', KEYS[2]) or '0')
|
||||
if user_limit > 0 and user_current + bytes > user_limit then
|
||||
return {0, 'user_redis_bytes', user_current}
|
||||
end
|
||||
end
|
||||
redis.call('INCRBY', KEYS[1], bytes)
|
||||
redis.call('EXPIRE', KEYS[1], ttl_seconds)
|
||||
if #KEYS >= 2 then
|
||||
redis.call('INCRBY', KEYS[2], bytes)
|
||||
redis.call('EXPIRE', KEYS[2], ttl_seconds)
|
||||
end
|
||||
return {1, 'ok', execution_current + bytes, user_current + bytes}
|
||||
`
|
||||
|
||||
const RELEASE_REDIS_BYTES_SCRIPT = `
|
||||
local bytes = tonumber(ARGV[1])
|
||||
for i = 1, #KEYS do
|
||||
local next_value = redis.call('DECRBY', KEYS[i], bytes)
|
||||
if next_value <= 0 then
|
||||
redis.call('DEL', KEYS[i])
|
||||
end
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
export type ExecutionRedisBudgetCategory = 'event_buffer' | 'base64_cache'
|
||||
|
||||
export interface ExecutionRedisBudgetReservation {
|
||||
executionId: string
|
||||
userId?: string
|
||||
category: ExecutionRedisBudgetCategory
|
||||
bytes: number
|
||||
operation: string
|
||||
logger?: Logger
|
||||
}
|
||||
|
||||
export function getExecutionRedisBudgetLimits() {
|
||||
return {
|
||||
maxSingleWriteBytes: MAX_SINGLE_REDIS_WRITE_BYTES,
|
||||
maxExecutionBytes: MAX_EXECUTION_REDIS_BYTES,
|
||||
maxUserBytes: MAX_USER_REDIS_BYTES,
|
||||
ttlSeconds: REDIS_BUDGET_TTL_SECONDS,
|
||||
}
|
||||
}
|
||||
|
||||
export function getExecutionRedisBudgetKeys(
|
||||
reservation: ExecutionRedisBudgetReservation
|
||||
): string[] {
|
||||
const keys = [`${REDIS_BUDGET_PREFIX}execution:${reservation.executionId}`]
|
||||
if (reservation.userId) {
|
||||
keys.push(`${REDIS_BUDGET_PREFIX}user:${reservation.userId}`)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export async function reserveExecutionRedisBytes(
|
||||
redis: RedisClient,
|
||||
reservation: ExecutionRedisBudgetReservation
|
||||
): Promise<void> {
|
||||
if (reservation.bytes <= 0) return
|
||||
|
||||
const limits = getExecutionRedisBudgetLimits()
|
||||
if (reservation.bytes > limits.maxSingleWriteBytes) {
|
||||
throw new ExecutionResourceLimitError({
|
||||
resource: 'redis_key_bytes',
|
||||
attemptedBytes: reservation.bytes,
|
||||
limitBytes: limits.maxSingleWriteBytes,
|
||||
})
|
||||
}
|
||||
|
||||
const keys = getExecutionRedisBudgetKeys(reservation)
|
||||
const result = (await redis.eval(
|
||||
RESERVE_REDIS_BYTES_SCRIPT,
|
||||
keys.length,
|
||||
...keys,
|
||||
reservation.bytes,
|
||||
limits.maxExecutionBytes,
|
||||
limits.maxUserBytes,
|
||||
limits.ttlSeconds
|
||||
)) as [number, string, number | string | null]
|
||||
|
||||
const [allowed, resource, current] = result
|
||||
if (allowed === 1) return
|
||||
|
||||
throw new ExecutionResourceLimitError({
|
||||
resource: resource === 'user_redis_bytes' ? 'user_redis_bytes' : 'execution_redis_bytes',
|
||||
attemptedBytes: reservation.bytes,
|
||||
currentBytes: Number(current ?? 0),
|
||||
limitBytes: resource === 'user_redis_bytes' ? limits.maxUserBytes : limits.maxExecutionBytes,
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseExecutionRedisBytes(
|
||||
redis: RedisClient,
|
||||
reservation: ExecutionRedisBudgetReservation
|
||||
): Promise<void> {
|
||||
if (reservation.bytes <= 0) return
|
||||
|
||||
try {
|
||||
const keys = getExecutionRedisBudgetKeys(reservation)
|
||||
await redis.eval(RELEASE_REDIS_BYTES_SCRIPT, keys.length, ...keys, reservation.bytes)
|
||||
} catch (error) {
|
||||
const log = reservation.logger ?? logger
|
||||
log.warn('Failed to release execution Redis budget reservation', {
|
||||
executionId: reservation.executionId,
|
||||
userId: reservation.userId,
|
||||
category: reservation.category,
|
||||
operation: reservation.operation,
|
||||
bytes: reservation.bytes,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const EXECUTION_RESOURCE_LIMIT_CODE = 'execution_resource_limit_exceeded' as const
|
||||
|
||||
export type ExecutionResourceLimitResource =
|
||||
| 'redis_key_bytes'
|
||||
| 'execution_redis_bytes'
|
||||
| 'user_redis_bytes'
|
||||
| 'execution_payload_bytes'
|
||||
|
||||
export interface ExecutionResourceLimitDetails {
|
||||
resource: ExecutionResourceLimitResource
|
||||
attemptedBytes: number
|
||||
limitBytes: number
|
||||
currentBytes?: number
|
||||
statusCode?: number
|
||||
}
|
||||
|
||||
export class ExecutionResourceLimitError extends Error {
|
||||
readonly code = EXECUTION_RESOURCE_LIMIT_CODE
|
||||
readonly statusCode: number
|
||||
readonly resource: ExecutionResourceLimitResource
|
||||
readonly attemptedBytes: number
|
||||
readonly limitBytes: number
|
||||
readonly currentBytes?: number
|
||||
|
||||
constructor(details: ExecutionResourceLimitDetails) {
|
||||
super('Execution memory limit exceeded. Reduce payload size and try again.')
|
||||
this.name = 'ExecutionResourceLimitError'
|
||||
this.resource = details.resource
|
||||
this.attemptedBytes = details.attemptedBytes
|
||||
this.limitBytes = details.limitBytes
|
||||
this.currentBytes = details.currentBytes
|
||||
this.statusCode = details.statusCode ?? (details.resource === 'user_redis_bytes' ? 429 : 413)
|
||||
}
|
||||
}
|
||||
|
||||
export function isExecutionResourceLimitError(
|
||||
error: unknown
|
||||
): error is ExecutionResourceLimitError {
|
||||
return (
|
||||
error instanceof ExecutionResourceLimitError ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { code?: unknown }).code === EXECUTION_RESOURCE_LIMIT_CODE)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { SandboxBroker } from '@/lib/execution/sandbox/types'
|
||||
import {
|
||||
fetchWorkspaceFileBuffer,
|
||||
getWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
|
||||
const logger = createLogger('SandboxWorkspaceFileBroker')
|
||||
|
||||
interface WorkspaceFileArgs {
|
||||
fileId: string
|
||||
}
|
||||
|
||||
interface WorkspaceFileResult {
|
||||
dataUri: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-side broker that resolves a workspace file id into a base64 data URI.
|
||||
*
|
||||
* Exposed to isolate code through `__brokers.workspaceFile(fileId)` and wrapped
|
||||
* by the task bootstrap as `getFileBase64(fileId)`.
|
||||
*/
|
||||
export const workspaceFileBroker: SandboxBroker<WorkspaceFileArgs, WorkspaceFileResult> = {
|
||||
name: 'workspaceFile',
|
||||
async handle(ctx, args) {
|
||||
if (!args || typeof args.fileId !== 'string' || args.fileId.length === 0) {
|
||||
throw new Error('workspaceFile broker requires a non-empty fileId')
|
||||
}
|
||||
if (!ctx.workspaceId) {
|
||||
throw new Error('workspaceFile broker requires a workspaceId')
|
||||
}
|
||||
|
||||
const record = await getWorkspaceFile(ctx.workspaceId, args.fileId)
|
||||
if (!record) {
|
||||
logger.warn('Workspace file not found for sandbox broker', {
|
||||
workspaceId: ctx.workspaceId,
|
||||
fileId: args.fileId,
|
||||
})
|
||||
throw new Error(`File not found: ${args.fileId}`)
|
||||
}
|
||||
|
||||
const buffer = await fetchWorkspaceFileBuffer(record)
|
||||
const mime = record.type || 'image/png'
|
||||
return { dataUri: `data:${mime};base64,${buffer.toString('base64')}` }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Minimal isolate-side shim run at the top of every bundle entry.
|
||||
*
|
||||
* Must execute BEFORE `process/browser` because that shim captures
|
||||
* `setTimeout` at module-init time. Timers themselves are installed by
|
||||
* `isolated-vm-worker.cjs` (delegated to Node's real timers via
|
||||
* `ivm.Reference` per laverdet/isolated-vm#136) BEFORE the bundle runs, so
|
||||
* `process/browser` picks up the real delegated `setTimeout`.
|
||||
*
|
||||
* The only thing this file still does is alias `global -> globalThis` for
|
||||
* UMD-style fallbacks inside the bundles. All other runtime surface
|
||||
* (`console`, `TextEncoder`, `TextDecoder`, timers) is installed by the
|
||||
* worker via `ivm.Callback` / `ivm.Reference` bridges to Node's native
|
||||
* implementations — no hand-rolled polyfill logic lives in the isolate.
|
||||
*/
|
||||
|
||||
const g: typeof globalThis & { global?: typeof globalThis } = globalThis
|
||||
|
||||
if (typeof g.global === 'undefined') g.global = globalThis
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Builds isolate-compatible bundles for the document-generation libraries.
|
||||
*
|
||||
* Each library is bundled with `target=browser, format=iife` so it can be
|
||||
* evaluated inside a V8 isolate that has no Node APIs (`require`, `process`,
|
||||
* `fs`). The emitted files attach their exports to `globalThis.__bundles[name]`
|
||||
* and are checked in so production images don't need the bundler at runtime.
|
||||
*
|
||||
* Run via: `bun run build:sandbox-bundles`.
|
||||
*/
|
||||
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('SandboxBundleBuild')
|
||||
|
||||
interface BunBuildResult {
|
||||
success: boolean
|
||||
logs: unknown[]
|
||||
outputs: Array<{ text: () => Promise<string> }>
|
||||
}
|
||||
interface BunBuildOptions {
|
||||
entrypoints: string[]
|
||||
target: string
|
||||
format: string
|
||||
minify: boolean
|
||||
sourcemap: string
|
||||
root: string
|
||||
}
|
||||
declare const Bun: { build: (opts: BunBuildOptions) => Promise<BunBuildResult> }
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const BUNDLES_DIR = HERE
|
||||
const ENTRIES_DIR = join(HERE, '.entries')
|
||||
const APP_SIM_ROOT = join(HERE, '..', '..', '..', '..')
|
||||
|
||||
interface BundleSpec {
|
||||
/** Key on `globalThis.__bundles`. */
|
||||
name: string
|
||||
/** Short filename written under `bundles/<file>.cjs`. */
|
||||
outFile: string
|
||||
/** Source of the entry file bun will bundle. */
|
||||
entry: string
|
||||
}
|
||||
|
||||
const POLYFILLS_PATH = join(HERE, '_polyfills.ts')
|
||||
const POLYFILL_PRELUDE = `
|
||||
// Isolate-side polyfills must execute BEFORE any other import (process/browser
|
||||
// captures setTimeout at module-init time). Keep this as the first import.
|
||||
import '${POLYFILLS_PATH}'
|
||||
import { Buffer as __BufferPolyfill } from 'buffer'
|
||||
import * as __processPolyfill from 'process/browser'
|
||||
if (typeof globalThis.Buffer === 'undefined') globalThis.Buffer = __BufferPolyfill
|
||||
if (typeof globalThis.process === 'undefined') globalThis.process = __processPolyfill
|
||||
`
|
||||
|
||||
const BUNDLES: ReadonlyArray<BundleSpec> = [
|
||||
{
|
||||
name: 'pdf-lib',
|
||||
outFile: 'pdf-lib.cjs',
|
||||
entry: `
|
||||
${POLYFILL_PRELUDE}
|
||||
import * as mod from 'pdf-lib'
|
||||
globalThis.__bundles = globalThis.__bundles || {}
|
||||
globalThis.__bundles['pdf-lib'] = mod
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'docx',
|
||||
outFile: 'docx.cjs',
|
||||
entry: `
|
||||
${POLYFILL_PRELUDE}
|
||||
import * as mod from 'docx'
|
||||
globalThis.__bundles = globalThis.__bundles || {}
|
||||
globalThis.__bundles['docx'] = mod
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'pptxgenjs',
|
||||
outFile: 'pptxgenjs.cjs',
|
||||
entry: `
|
||||
${POLYFILL_PRELUDE}
|
||||
import PptxGenJS from 'pptxgenjs'
|
||||
globalThis.__bundles = globalThis.__bundles || {}
|
||||
globalThis.__bundles['pptxgenjs'] = PptxGenJS
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
async function main(): Promise<void> {
|
||||
rmSync(ENTRIES_DIR, { recursive: true, force: true })
|
||||
mkdirSync(ENTRIES_DIR, { recursive: true })
|
||||
mkdirSync(BUNDLES_DIR, { recursive: true })
|
||||
|
||||
for (const spec of BUNDLES) {
|
||||
const entryPath = join(ENTRIES_DIR, `${spec.name}.entry.ts`)
|
||||
writeFileSync(entryPath, spec.entry, 'utf-8')
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entryPath],
|
||||
target: 'browser',
|
||||
format: 'iife',
|
||||
minify: true,
|
||||
sourcemap: 'none',
|
||||
root: APP_SIM_ROOT,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) {
|
||||
logger.error(String(log))
|
||||
}
|
||||
throw new Error(`Failed to build sandbox bundle: ${spec.name}`)
|
||||
}
|
||||
|
||||
if (result.outputs.length === 0) {
|
||||
throw new Error(`No output produced for sandbox bundle: ${spec.name}`)
|
||||
}
|
||||
|
||||
const code = await result.outputs[0].text()
|
||||
const banner = `// sandbox bundle: ${spec.name}\n// generated by apps/sim/lib/execution/sandbox/bundles/build.ts\n// do not edit by hand. run \`bun run build:sandbox-bundles\` to regenerate.\n`
|
||||
writeFileSync(join(BUNDLES_DIR, spec.outFile), banner + code, 'utf-8')
|
||||
logger.info(`built ${spec.outFile} (${code.length.toLocaleString()} chars)`)
|
||||
}
|
||||
|
||||
rmSync(ENTRIES_DIR, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error('sandbox bundle build failed', err)
|
||||
process.exit(1)
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
import type { SandboxTask, SandboxTaskInput } from '@/lib/execution/sandbox/types'
|
||||
|
||||
/**
|
||||
* Helper that preserves the task's input type through declaration.
|
||||
* Mirrors the `task(...)` / `defineConfig(...)` pattern used elsewhere in the
|
||||
* codebase so sandbox tasks look familiar next to trigger.dev tasks.
|
||||
*/
|
||||
export function defineSandboxTask<TInput extends SandboxTaskInput = SandboxTaskInput>(
|
||||
task: SandboxTask<TInput>
|
||||
): SandboxTask<TInput> {
|
||||
if (!task.id || !/^[a-z][a-z0-9-]*$/.test(task.id)) {
|
||||
throw new Error(`Sandbox task id must be kebab-case: got "${task.id}"`)
|
||||
}
|
||||
const brokerNames = new Set<string>()
|
||||
for (const broker of task.brokers) {
|
||||
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(broker.name)) {
|
||||
throw new Error(
|
||||
`Sandbox broker name must be a valid JS identifier: got "${broker.name}" on task "${task.id}"`
|
||||
)
|
||||
}
|
||||
if (brokerNames.has(broker.name)) {
|
||||
throw new Error(`Duplicate broker name "${broker.name}" on task "${task.id}"`)
|
||||
}
|
||||
brokerNames.add(broker.name)
|
||||
}
|
||||
return task
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import {
|
||||
executeInIsolatedVM,
|
||||
type IsolatedVMBrokerHandler,
|
||||
type IsolatedVMExecutionRequest,
|
||||
} from '@/lib/execution/isolated-vm'
|
||||
import type { SandboxBrokerContext, SandboxTaskInput } from '@/lib/execution/sandbox/types'
|
||||
import { getSandboxTask, type SandboxTaskId } from '@/sandbox-tasks/registry'
|
||||
|
||||
const logger = createLogger('SandboxRunTask')
|
||||
|
||||
export interface RunSandboxTaskOptions {
|
||||
/**
|
||||
* Owner key used by the isolated-vm pool for fairness + distributed leases.
|
||||
* Typically `user:<userId>` or `workspace:<workspaceId>`.
|
||||
*/
|
||||
ownerKey?: string
|
||||
/** Optional AbortSignal to cancel the execution early. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the sandbox failure is attributable to the caller — user code
|
||||
* errors (SyntaxError, ReferenceError, user-thrown exceptions), timeouts from
|
||||
* user code, client aborts, or per-owner rate limits. Callers should translate
|
||||
* this into a 4xx response so genuine 5xx remains a signal of server health.
|
||||
*
|
||||
* System-origin failures (worker crash, IPC failure, pool saturation, task
|
||||
* misconfig) are tagged with `isSystemError` at the isolated-vm layer and
|
||||
* surface as a plain `Error` → 500.
|
||||
*/
|
||||
export class SandboxUserCodeError extends Error {
|
||||
constructor(message: string, name: string, stack?: string) {
|
||||
super(message)
|
||||
this.name = name || 'SandboxUserCodeError'
|
||||
if (stack) this.stack = stack
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a sandbox task inside the shared isolated-vm pool and returns the
|
||||
* binary result buffer. Throws with a human-readable message if the task fails
|
||||
* so callers can propagate the error verbatim to UI.
|
||||
*/
|
||||
export async function runSandboxTask<TInput extends SandboxTaskInput>(
|
||||
taskId: SandboxTaskId,
|
||||
input: TInput,
|
||||
options: RunSandboxTaskOptions = {}
|
||||
): Promise<Buffer> {
|
||||
const task = getSandboxTask(taskId)
|
||||
const requestId = generateShortId(12)
|
||||
|
||||
const brokerContext: SandboxBrokerContext = {
|
||||
workspaceId: input.workspaceId,
|
||||
requestId,
|
||||
}
|
||||
const brokers: Record<string, IsolatedVMBrokerHandler> = {}
|
||||
for (const broker of task.brokers) {
|
||||
brokers[broker.name] = (args) => broker.handle(brokerContext, args)
|
||||
}
|
||||
|
||||
const request: IsolatedVMExecutionRequest = {
|
||||
code: input.code,
|
||||
params: {},
|
||||
envVars: {},
|
||||
contextVariables: {},
|
||||
timeoutMs: task.timeoutMs,
|
||||
requestId,
|
||||
ownerKey: options.ownerKey,
|
||||
ownerWeight: 1,
|
||||
task: {
|
||||
id: task.id,
|
||||
bundles: [...task.bundles],
|
||||
bootstrap: task.bootstrap,
|
||||
brokers: task.brokers.map((b) => b.name),
|
||||
finalize: task.finalize,
|
||||
},
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
const result = await executeInIsolatedVM(request, { brokers, signal: options.signal })
|
||||
const elapsedMs = Date.now() - start
|
||||
|
||||
// Phase timings come from the worker (see executeTask). `queue` is the
|
||||
// gap between client call and worker-side start — useful for diagnosing
|
||||
// pool saturation vs. isolate-internal slowness.
|
||||
const queueMs = result.timings ? Math.max(0, elapsedMs - result.timings.total) : undefined
|
||||
|
||||
if (result.error) {
|
||||
const isSystemError = result.error.isSystemError === true
|
||||
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
|
||||
logFn('Sandbox task failed', {
|
||||
taskId,
|
||||
requestId,
|
||||
workspaceId: input.workspaceId,
|
||||
elapsedMs,
|
||||
queueMs,
|
||||
timings: result.timings,
|
||||
error: result.error.message,
|
||||
errorName: result.error.name,
|
||||
isSystemError,
|
||||
})
|
||||
if (isSystemError) {
|
||||
const err = new Error(result.error.message)
|
||||
err.name = result.error.name || 'SandboxSystemError'
|
||||
if (result.error.stack) err.stack = result.error.stack
|
||||
throw err
|
||||
}
|
||||
throw new SandboxUserCodeError(
|
||||
result.error.message,
|
||||
result.error.name || 'SandboxTaskError',
|
||||
result.error.stack
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof result.bytesBase64 !== 'string' || result.bytesBase64.length === 0) {
|
||||
logger.error('Sandbox task returned no bytes', {
|
||||
taskId,
|
||||
requestId,
|
||||
workspaceId: input.workspaceId,
|
||||
timings: result.timings,
|
||||
})
|
||||
throw new Error(`Sandbox task "${taskId}" finalize did not return any bytes`)
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(result.bytesBase64, 'base64')
|
||||
logger.info('Sandbox task completed', {
|
||||
taskId,
|
||||
requestId,
|
||||
workspaceId: input.workspaceId,
|
||||
elapsedMs,
|
||||
queueMs,
|
||||
timings: result.timings,
|
||||
bytes: bytes.length,
|
||||
})
|
||||
return task.toResult(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), input)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Types for the sandbox task system.
|
||||
*
|
||||
* A `SandboxTask` is a recipe that tells the isolated-vm pool how to run a
|
||||
* particular kind of user code: which pre-built library bundles to install,
|
||||
* which host-side brokers to expose, and how to serialize the final result.
|
||||
*/
|
||||
|
||||
export type SandboxBundleName = 'pptxgenjs' | 'docx' | 'pdf-lib'
|
||||
|
||||
export interface SandboxBroker<TArgs = unknown, TResult = unknown> {
|
||||
/**
|
||||
* Name the isolate-side bootstrap references (e.g. `__brokers.workspaceFile`).
|
||||
* Must be a plain JS identifier segment.
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Host-side handler invoked when the isolate calls the broker.
|
||||
* `ctx` carries per-execution metadata (workspaceId, requestId, etc.).
|
||||
*/
|
||||
handle(ctx: SandboxBrokerContext, args: TArgs): Promise<TResult>
|
||||
}
|
||||
|
||||
export interface SandboxBrokerContext {
|
||||
workspaceId: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface SandboxTaskInput {
|
||||
workspaceId: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface SandboxTask<TInput extends SandboxTaskInput = SandboxTaskInput> {
|
||||
/** Kebab-case stable identifier, used for logging + lookups. */
|
||||
id: string
|
||||
/** Script execution timeout inside the isolate. */
|
||||
timeoutMs: number
|
||||
/** Library bundles to load as isolate globals before the bootstrap runs. */
|
||||
bundles: ReadonlyArray<SandboxBundleName>
|
||||
/** Host-side brokers this task is allowed to call from inside the isolate. */
|
||||
brokers: ReadonlyArray<SandboxBroker>
|
||||
/**
|
||||
* JS code run inside the isolate after bundles are installed and before
|
||||
* user code. Should hoist bundle globals to friendly names and install any
|
||||
* helper functions users expect (e.g. `getFileBase64`).
|
||||
*/
|
||||
bootstrap: string
|
||||
/**
|
||||
* JS source that, when evaluated inside an async IIFE after user code, must
|
||||
* return a `Uint8Array`. The bytes are transferred out via `ExternalCopy`.
|
||||
*/
|
||||
finalize: string
|
||||
/** Host-side transform from raw isolate bytes to the caller's return type. */
|
||||
toResult(bytes: Uint8Array, input: TInput): Buffer
|
||||
}
|
||||
Reference in New Issue
Block a user