Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
coverage/
*.log
.DS_Store
/.claude/
.open-flow-dev/
.wrangler/

Expand Down
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"js/ts.experimental.useTsgo": true,
"js/ts.tsdk.path": "./node_modules/tsrs/tsdk",
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
Expand Down
106 changes: 106 additions & 0 deletions apps/server/node/application/execution-slots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import * as Effect from 'effect/Effect'
import * as Semaphore from 'effect/Semaphore'

interface Lease {
readonly runId: string
readonly abort: AbortController
held: boolean
/** Outstanding child waits; the lease holds no permit while any remain. */
suspended: number
taking: Promise<void> | undefined
ended: boolean
}

/** Deployment-wide Task budget, held per node invocation and lent out while it waits for children. */
export class ExecutionSlots {
readonly #semaphore: Semaphore.Semaphore
readonly #leases = new Map<string, Lease>()

constructor(limit: number) {
this.#semaphore = Semaphore.makeUnsafe(limit)
}

/** Admits one node invocation; repeated admission of a live invocation shares its lease. */
acquire(runId: string, jobId: string, signal: AbortSignal): Promise<void> {
signal.throwIfAborted()
const key = JSON.stringify([runId, jobId])
let lease = this.#leases.get(key)
if (lease == null) {
lease = { runId, abort: new AbortController(), held: false, suspended: 0, taking: undefined, ended: false }
this.#leases.set(key, lease)
}
return this.#hold(lease, signal)
}

/** Lends the permit to child work until every overlapping child wait has resumed. */
suspend(runId: string, jobId: string): void {
const lease = this.#leases.get(JSON.stringify([runId, jobId]))
if (lease == null) return
lease.suspended++
if (lease.suspended == 1) this.#drop(lease)
}

/** Ends one child wait; the last one reacquires the permit before the invocation continues. */
resume(runId: string, jobId: string, signal: AbortSignal): Promise<void> {
const key = JSON.stringify([runId, jobId])
const lease = this.#leases.get(key)
if (lease == null || lease.suspended == 0) return Promise.resolve()
lease.suspended--
return lease.suspended > 0 ? Promise.resolve() : this.#hold(lease, signal)
}

release(runId: string, jobId: string): void {
const key = JSON.stringify([runId, jobId])
const lease = this.#leases.get(key)
if (lease != null) this.#end(key, lease, 'Node execution ended.')
}

releaseRun(runId: string): void {
for (const [key, lease] of this.#leases) if (lease.runId == runId) this.#end(key, lease, 'Run execution ended.')
}

#hold(lease: Lease, signal: AbortSignal): Promise<void> {
signal.throwIfAborted()
if (lease.held || lease.suspended > 0) return Promise.resolve()
if (lease.taking != null) return lease.taking
const taking = Effect.runPromise(
Effect.uninterruptibleMask((restore) =>
restore(this.#semaphore.take(1)).pipe(
Effect.andThen(
Effect.suspend(() => {
// A permit that arrives after the lease ended or lent itself out is returned immediately.
if (lease.ended || lease.suspended > 0) return Effect.asVoid(this.#semaphore.release(1))
lease.held = true
return Effect.void
}),
),
),
),
{ signal: AbortSignal.any([signal, lease.abort.signal]) },
).then(
() => {
if (lease.taking == taking) lease.taking = undefined
},
(error: unknown) => {
// The lease itself ends only through release or releaseRun.
if (lease.taking == taking) lease.taking = undefined
throw error
},
)
lease.taking = taking
return taking
}

#drop(lease: Lease): void {
if (!lease.held) return
lease.held = false
Effect.runSync(this.#semaphore.release(1))
}

#end(key: string, lease: Lease, reason: string): void {
this.#leases.delete(key)
lease.ended = true
lease.abort.abort(new Error(reason))
this.#drop(lease)
}
}
38 changes: 32 additions & 6 deletions apps/server/node/application/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ import { HttpTaskError } from '../deployment/http-transport.ts'
import { executeOpenApi } from '../deployment/openapi.ts'
import { WorkflowToolError } from '../deployment/workflow-contract.ts'
import { errorKind } from '../logger.ts'
import { supportsIsolatedVmEngine, IsolatedVmHost } from '../runtime/isolated-vm.ts'
import { supportsIsolatedVmEngine, IsolatedVmError, IsolatedVmHost } from '../runtime/isolated-vm.ts'
import { checkCodeActions, checkCodePermissions, checkConnectorProxies } from './connector-capabilities.ts'
import { ExecutionSlots } from './execution-slots.ts'
import { decodedRevision, preparedRevision } from './revision-cache.ts'
import { ArtifactHostError, RunArtifacts } from './run-artifacts.ts'
import { RunState } from './run-state.ts'
Expand Down Expand Up @@ -114,6 +115,7 @@ export class RunExecutor {
readonly #toolCalls = new Map<string, { runId: string; jobId: string; abort: AbortController }>()
readonly #state: RunState
readonly #artifacts: RunArtifacts
readonly #slots: ExecutionSlots

constructor(
store: Store,
Expand All @@ -129,7 +131,9 @@ export class RunExecutor {
invokeWorkflowTarget: RunControl['invokeWorkflowTarget'],
subscribeRun: (flowId: string, runId: string, changed: () => void) => () => void,
additionalFailureCodes: ReadonlySet<string>,
maxConcurrentTasks: number,
) {
this.#slots = new ExecutionSlots(maxConcurrentTasks)
this.#invokeWorkflowTarget = invokeWorkflowTarget
this.#subscribeRun = subscribeRun
this.#invokeWorkflow = invokeWorkflow
Expand Down Expand Up @@ -313,6 +317,7 @@ export class RunExecutor {
const emit = async (event: Parameters<NonNullable<Parameters<IsolatedVmHost['run']>[1]['emit']>>[0] | ArtifactEvent): Promise<void> => {
if (event.type == 'run.started' && event.runId == run.runId) return
if ((event.type == 'node.completed' || event.type == 'node.failed') && 'jobId' in event) {
this.#slots.release(run.runId, event.jobId)
connectors.release(event.jobId)
if (event.type === 'node.failed') this.#store.state.discardInvocation(run.runId, event.jobId)
this.#cancelNodeTools(run.runId, event.jobId)
Expand All @@ -325,6 +330,7 @@ export class RunExecutor {
.run(flow, {
capability: (capabilities, call) => this.#invokeCapability(capabilities, call, this.#connectorContext(run, 'execute'), run, flow, emit),
emit,
admitTask: (admission, signal) => this.#slots.acquire(admission.runId, admission.jobId, signal),
emitLogs: async (events, signal) => {
const projected = (await Promise.all(events.map((event) => projectEvent(event).catch(() => undefined)))).filter((event) => event != null)
if (signal.aborted) return
Expand Down Expand Up @@ -362,6 +368,7 @@ export class RunExecutor {
}
}
const output = this.#store.runs.createWait(run.runId, request, this.#resolveWaitPublicOrigin()?.href, notification)
this.#slots.release(run.runId, request.jobId)
this.#runChanged(run.flowId, run.runId)
this.#wakeMaintenance()
return output
Expand Down Expand Up @@ -405,13 +412,27 @@ export class RunExecutor {
})
.pipe(
Effect.matchEffect({
onFailure: (error) => Effect.sync(() => this.#failRun(run, startedAt, error.message.includes('execution deadline'), error, indeterminate)),
onFailure: (error) =>
Effect.sync(() =>
this.#failRun(
run,
startedAt,
error.message.includes('execution deadline'),
error,
indeterminate || (error instanceof IsolatedVmError && error.code == 'executor-crashed'),
),
),
onSuccess: (output) =>
Effect.try({ try: () => this.#commitOutcome(run, startedAt, budgetMs, output), catch: (error) => error }).pipe(
Effect.catch((error) => Effect.sync(() => this.#failRun(run, startedAt, false, error))),
),
}),
Effect.ensuring(Effect.sync(() => this.#artifacts.discard(run.runId))),
Effect.ensuring(
Effect.sync(() => {
this.#slots.releaseRun(run.runId)
this.#artifacts.discard(run.runId)
}),
),
)
})
}
Expand All @@ -432,7 +453,7 @@ export class RunExecutor {

#failRun(run: StoredRun, startedAt: number, timedOut: boolean, error: unknown, indeterminate = false): void {
const result = indeterminate
? { error: { code: 'execution.terminal-unknown', message: 'A Connector action outcome is unknown.' } }
? { error: { code: 'execution.terminal-unknown', message: 'An execution outcome is unknown.' } }
: timedOut
? { error: { code: 'run.timeout', message: 'The Run exceeded its execution deadline.' } }
: { error: { code: 'run.failed', message: 'The Flow could not be completed.' } }
Expand Down Expand Up @@ -473,8 +494,11 @@ export class RunExecutor {
const task = node.task
const executor = task.executor
switch (executor.kind) {
case 'workflow':
return this.#invokeWorkflow(run, invocation.nodeId, invocation.jobId, invocation.input, node, invocation.workflowRunId)
case 'workflow': {
const result = await this.#invokeWorkflow(run, invocation.nodeId, invocation.jobId, invocation.input, node, invocation.workflowRunId)
if (result.kind == 'calling') this.#slots.release(run.runId, invocation.jobId)
return result
}
case 'skip-duplicates': {
if (run.source == null) throw new StateError('capability.denied', 'State requires a Run source.')
const items = invocation.input.items!
Expand Down Expand Up @@ -757,6 +781,7 @@ export class RunExecutor {
signal.throwIfAborted()
this.#pendingWorkflowCalls.set(run.runId, (this.#pendingWorkflowCalls.get(run.runId) ?? 0) + 1)
this.waitingWorkflows.add(run.runId)
this.#slots.suspend(run.runId, jobId)
this.#wakeMaintenance()
try {
return await new Promise<Readonly<Record<string, JsonValue>>>((resolve, reject) => {
Expand Down Expand Up @@ -791,6 +816,7 @@ export class RunExecutor {
if (pending == 0) this.#pendingWorkflowCalls.delete(run.runId)
else this.#pendingWorkflowCalls.set(run.runId, pending)
await this.#resumeWorkflowCaller(run.runId, signal)
if (!signal.aborted) await this.#slots.resume(run.runId, jobId, signal)
}
} finally {
this.#toolCalls.delete(key)
Expand Down
2 changes: 2 additions & 0 deletions apps/server/node/application/service-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { IntegrationOptions } from '../runtime/integration-runtime.ts'
export interface ServerRuntime {
readonly stateLimits?: Partial<typeof import('../storage/state-store.ts').stateLimits>
readonly maxConcurrentRuns?: number
readonly maxConcurrentTasks?: number
readonly maxPendingRuns?: number
readonly runEventRetentionMs?: number
readonly runTimeoutMs?: number
Expand Down Expand Up @@ -56,6 +57,7 @@ export function validateRuntime(runtime: ServerRuntime): void {
validatePositiveInteger(runtime.runEventRetentionMs, 'Run event retention must be a positive safe integer number of milliseconds.')
validatePositiveInteger(runtime.maxPendingRuns, 'Maximum pending Runs must be a positive safe integer.')
validatePositiveInteger(runtime.maxConcurrentRuns, 'Maximum concurrent Runs must be a positive safe integer.')
validatePositiveInteger(runtime.maxConcurrentTasks, 'Maximum concurrent Tasks must be a positive safe integer.')
validatePositiveInteger(runtime.runTimeoutMs, 'Run timeout must be a positive safe integer number of milliseconds.')
validatePositiveInteger(runtime.taskCpuMs, 'Task CPU limit must be a positive safe integer number of milliseconds.')
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/node/application/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export class ServerService {
if (event.kind == 'run.changed' && event.runId == runId) changed()
}),
connectorFailureCodes,
runtime.maxConcurrentTasks ?? 16,
)
const pollDefinitions = triggerDefinitions.filter((definition): definition is PollDefinition => definition.snapshot.type == 'poll')
const integrationDefinitions = triggerDefinitions.filter((definition): definition is IntegrationDefinition => definition.snapshot.type == 'integration')
Expand Down
5 changes: 5 additions & 0 deletions apps/server/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ function main(): Effect.Effect<void> {
if (!Number.isSafeInteger(maxConcurrentRuns) || maxConcurrentRuns <= 0) {
throw new Error('OPEN_FLOW_MAX_CONCURRENT_RUNS must be a positive safe integer.')
}
const maxConcurrentTasks = Number(process.env.OPEN_FLOW_MAX_CONCURRENT_TASKS ?? '16')
if (!Number.isSafeInteger(maxConcurrentTasks) || maxConcurrentTasks <= 0) {
throw new Error('OPEN_FLOW_MAX_CONCURRENT_TASKS must be a positive safe integer.')
}
const runTimeoutMs = Number(process.env.OPEN_FLOW_RUN_TIMEOUT_MS ?? '1800000')
if (!Number.isSafeInteger(runTimeoutMs) || runTimeoutMs <= 0) {
throw new Error('OPEN_FLOW_RUN_TIMEOUT_MS must be a positive safe integer.')
Expand Down Expand Up @@ -127,6 +131,7 @@ function main(): Effect.Effect<void> {
logger,
runtime: {
maxConcurrentRuns,
maxConcurrentTasks,
maxPendingRuns,
runEventRetentionMs,
runTimeoutMs,
Expand Down
Loading
Loading