@elaraai/e3-core 0.0.2-beta.36 → 0.0.2-beta.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/dataflow/api-compat.d.ts.map +1 -1
- package/dist/src/dataflow/api-compat.js +6 -1
- package/dist/src/dataflow/api-compat.js.map +1 -1
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts +22 -4
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts.map +1 -1
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.js +353 -79
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.js.map +1 -1
- package/dist/src/dataflow/orchestrator/interfaces.d.ts +6 -0
- package/dist/src/dataflow/orchestrator/interfaces.d.ts.map +1 -1
- package/dist/src/dataflow/orchestrator/interfaces.js +1 -0
- package/dist/src/dataflow/orchestrator/interfaces.js.map +1 -1
- package/dist/src/dataflow/state-store/InMemoryStateStore.d.ts.map +1 -1
- package/dist/src/dataflow/state-store/InMemoryStateStore.js +8 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.js.map +1 -1
- package/dist/src/dataflow/steps.d.ts +74 -28
- package/dist/src/dataflow/steps.d.ts.map +1 -1
- package/dist/src/dataflow/steps.js +221 -42
- package/dist/src/dataflow/steps.js.map +1 -1
- package/dist/src/dataflow/types.d.ts +13 -2
- package/dist/src/dataflow/types.d.ts.map +1 -1
- package/dist/src/dataflow.d.ts +37 -95
- package/dist/src/dataflow.d.ts.map +1 -1
- package/dist/src/dataflow.js +121 -631
- package/dist/src/dataflow.js.map +1 -1
- package/dist/src/dataset-refs.d.ts +124 -0
- package/dist/src/dataset-refs.d.ts.map +1 -0
- package/dist/src/dataset-refs.js +319 -0
- package/dist/src/dataset-refs.js.map +1 -0
- package/dist/src/execution/MockTaskRunner.d.ts +1 -1
- package/dist/src/execution/MockTaskRunner.d.ts.map +1 -1
- package/dist/src/execution/MockTaskRunner.js +1 -2
- package/dist/src/execution/MockTaskRunner.js.map +1 -1
- package/dist/src/index.d.ts +5 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/packages.d.ts.map +1 -1
- package/dist/src/packages.js +20 -7
- package/dist/src/packages.js.map +1 -1
- package/dist/src/storage/in-memory/InMemoryStorage.d.ts +26 -4
- package/dist/src/storage/in-memory/InMemoryStorage.d.ts.map +1 -1
- package/dist/src/storage/in-memory/InMemoryStorage.js +104 -21
- package/dist/src/storage/in-memory/InMemoryStorage.js.map +1 -1
- package/dist/src/storage/index.d.ts +2 -2
- package/dist/src/storage/index.d.ts.map +1 -1
- package/dist/src/storage/index.js +1 -1
- package/dist/src/storage/index.js.map +1 -1
- package/dist/src/storage/interfaces.d.ts +52 -1
- package/dist/src/storage/interfaces.d.ts.map +1 -1
- package/dist/src/storage/local/LocalBackend.d.ts +3 -1
- package/dist/src/storage/local/LocalBackend.d.ts.map +1 -1
- package/dist/src/storage/local/LocalBackend.js +5 -1
- package/dist/src/storage/local/LocalBackend.js.map +1 -1
- package/dist/src/storage/local/LocalDatasetRefStore.d.ts +22 -0
- package/dist/src/storage/local/LocalDatasetRefStore.d.ts.map +1 -0
- package/dist/src/storage/local/LocalDatasetRefStore.js +118 -0
- package/dist/src/storage/local/LocalDatasetRefStore.js.map +1 -0
- package/dist/src/storage/local/LocalLockService.d.ts +6 -0
- package/dist/src/storage/local/LocalLockService.d.ts.map +1 -1
- package/dist/src/storage/local/LocalLockService.js +17 -4
- package/dist/src/storage/local/LocalLockService.js.map +1 -1
- package/dist/src/storage/local/LocalRepoStore.d.ts +4 -2
- package/dist/src/storage/local/LocalRepoStore.d.ts.map +1 -1
- package/dist/src/storage/local/LocalRepoStore.js +14 -2
- package/dist/src/storage/local/LocalRepoStore.js.map +1 -1
- package/dist/src/storage/local/gc.d.ts.map +1 -1
- package/dist/src/storage/local/gc.js +8 -1
- package/dist/src/storage/local/gc.js.map +1 -1
- package/dist/src/storage/local/index.d.ts +1 -0
- package/dist/src/storage/local/index.d.ts.map +1 -1
- package/dist/src/storage/local/index.js +1 -0
- package/dist/src/storage/local/index.js.map +1 -1
- package/dist/src/trees.d.ts +35 -43
- package/dist/src/trees.d.ts.map +1 -1
- package/dist/src/trees.js +228 -449
- package/dist/src/trees.js.map +1 -1
- package/dist/src/workspaces.d.ts +6 -27
- package/dist/src/workspaces.d.ts.map +1 -1
- package/dist/src/workspaces.js +42 -55
- package/dist/src/workspaces.js.map +1 -1
- package/package.json +1 -1
package/dist/src/dataflow.js
CHANGED
|
@@ -5,29 +5,19 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* Dataflow execution for e3 workspaces.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Provides the high-level `dataflowExecute` entry point (which delegates
|
|
9
|
+
* to `LocalOrchestrator`) and shared graph-building utilities used by
|
|
10
|
+
* both local and cloud execution paths.
|
|
10
11
|
*
|
|
11
|
-
* The execution
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 3. Initialize ready queue with tasks whose inputs are all assigned
|
|
15
|
-
* 4. Execute tasks from ready queue, respecting concurrency limit
|
|
16
|
-
* 5. On task completion, queue workspace update then check dependents for readiness
|
|
17
|
-
* 6. On failure, stop launching new tasks but wait for running ones
|
|
18
|
-
*
|
|
19
|
-
* IMPORTANT: Workspace state updates are serialized through an async queue to
|
|
20
|
-
* prevent race conditions when multiple tasks complete concurrently. Each task's
|
|
21
|
-
* output is written to the workspace and dependents are notified only after the
|
|
22
|
-
* write completes, ensuring downstream tasks see consistent state.
|
|
12
|
+
* The reactive execution logic (input change detection, task invalidation,
|
|
13
|
+
* version vector consistency) lives in `dataflow/steps.ts` and is orchestrated
|
|
14
|
+
* by `dataflow/orchestrator/LocalOrchestrator.ts`.
|
|
23
15
|
*/
|
|
24
|
-
import { decodeBeast2For,
|
|
16
|
+
import { decodeBeast2For, variant } from '@elaraai/east';
|
|
25
17
|
import { PackageObjectType, TaskObjectType, WorkspaceStateType, pathToString, } from '@elaraai/e3-types';
|
|
26
18
|
import { executionGetOutput, inputsHash, } from './executions.js';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { workspaceGetDatasetHash, workspaceSetDatasetByHash, } from './trees.js';
|
|
30
|
-
import { E3Error, WorkspaceNotFoundError, WorkspaceNotDeployedError, WorkspaceLockError, TaskNotFoundError, DataflowError, DataflowAbortedError, } from './errors.js';
|
|
19
|
+
import { workspaceGetDatasetHash, } from './trees.js';
|
|
20
|
+
import { E3Error, WorkspaceNotFoundError, WorkspaceNotDeployedError, DataflowError, } from './errors.js';
|
|
31
21
|
// =============================================================================
|
|
32
22
|
// Path Parsing Helper
|
|
33
23
|
// =============================================================================
|
|
@@ -76,53 +66,6 @@ export function parsePathString(pathStr) {
|
|
|
76
66
|
return segments;
|
|
77
67
|
}
|
|
78
68
|
// =============================================================================
|
|
79
|
-
// Async Mutex for Workspace Updates
|
|
80
|
-
// =============================================================================
|
|
81
|
-
/**
|
|
82
|
-
* Simple async mutex to serialize workspace state updates.
|
|
83
|
-
*
|
|
84
|
-
* When multiple tasks complete concurrently, their workspace writes must be
|
|
85
|
-
* serialized to prevent race conditions (read-modify-write on the workspace
|
|
86
|
-
* root hash). This mutex ensures only one update runs at a time.
|
|
87
|
-
*/
|
|
88
|
-
class AsyncMutex {
|
|
89
|
-
queue = [];
|
|
90
|
-
locked = false;
|
|
91
|
-
/**
|
|
92
|
-
* Acquire the mutex, execute the callback, then release.
|
|
93
|
-
* If the mutex is already held, waits until it's available.
|
|
94
|
-
*/
|
|
95
|
-
async runExclusive(fn) {
|
|
96
|
-
await this.acquire();
|
|
97
|
-
try {
|
|
98
|
-
return await fn();
|
|
99
|
-
}
|
|
100
|
-
finally {
|
|
101
|
-
this.release();
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
acquire() {
|
|
105
|
-
return new Promise((resolve) => {
|
|
106
|
-
if (!this.locked) {
|
|
107
|
-
this.locked = true;
|
|
108
|
-
resolve();
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
this.queue.push(resolve);
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
release() {
|
|
116
|
-
const next = this.queue.shift();
|
|
117
|
-
if (next) {
|
|
118
|
-
next();
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
this.locked = false;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
// =============================================================================
|
|
126
69
|
// Workspace State Reader
|
|
127
70
|
// =============================================================================
|
|
128
71
|
/**
|
|
@@ -146,22 +89,14 @@ async function readWorkspaceState(storage, repo, ws) {
|
|
|
146
89
|
// =============================================================================
|
|
147
90
|
/**
|
|
148
91
|
* Build the dependency graph for a workspace.
|
|
149
|
-
*
|
|
150
|
-
* Returns:
|
|
151
|
-
* - taskNodes: Map of task name -> TaskNode
|
|
152
|
-
* - outputToTask: Map of output path string -> task name
|
|
153
|
-
* - taskDependents: Map of task name -> set of dependent task names
|
|
154
92
|
*/
|
|
155
93
|
async function buildDependencyGraph(storage, repo, ws) {
|
|
156
|
-
// Read workspace state to get package hash
|
|
157
94
|
const state = await readWorkspaceState(storage, repo, ws);
|
|
158
|
-
// Read package object to get tasks map
|
|
159
95
|
const pkgData = await storage.objects.read(repo, state.packageHash);
|
|
160
96
|
const pkgDecoder = decodeBeast2For(PackageObjectType);
|
|
161
97
|
const pkgObject = pkgDecoder(Buffer.from(pkgData));
|
|
162
98
|
const taskNodes = new Map();
|
|
163
|
-
const outputToTask = new Map();
|
|
164
|
-
// First pass: load all tasks and build output->task map
|
|
99
|
+
const outputToTask = new Map();
|
|
165
100
|
const taskDecoder = decodeBeast2For(TaskObjectType);
|
|
166
101
|
for (const [taskName, taskHash] of pkgObject.tasks) {
|
|
167
102
|
const taskData = await storage.objects.read(repo, taskHash);
|
|
@@ -174,32 +109,24 @@ async function buildDependencyGraph(storage, repo, ws) {
|
|
|
174
109
|
task,
|
|
175
110
|
inputPaths: task.inputs,
|
|
176
111
|
outputPath: task.output,
|
|
177
|
-
unresolvedCount: 0,
|
|
112
|
+
unresolvedCount: 0,
|
|
178
113
|
});
|
|
179
114
|
}
|
|
180
|
-
// Build reverse dependency map: task -> tasks that depend on it
|
|
181
115
|
const taskDependents = new Map();
|
|
182
116
|
for (const taskName of taskNodes.keys()) {
|
|
183
117
|
taskDependents.set(taskName, new Set());
|
|
184
118
|
}
|
|
185
|
-
// Second pass: compute dependencies and unresolved counts
|
|
186
119
|
for (const [taskName, node] of taskNodes) {
|
|
187
120
|
for (const inputPath of node.inputPaths) {
|
|
188
121
|
const inputPathStr = pathToString(inputPath);
|
|
189
122
|
const producerTask = outputToTask.get(inputPathStr);
|
|
190
123
|
if (producerTask) {
|
|
191
|
-
// This input comes from another task's output.
|
|
192
|
-
// The task cannot run until the producer task completes,
|
|
193
|
-
// regardless of whether the output is currently assigned
|
|
194
|
-
// (it might be stale from a previous run).
|
|
195
124
|
taskDependents.get(producerTask).add(taskName);
|
|
196
125
|
node.unresolvedCount++;
|
|
197
126
|
}
|
|
198
|
-
// If not produced by a task, it's an external input - check if assigned
|
|
199
127
|
else {
|
|
200
128
|
const { refType } = await workspaceGetDatasetHash(storage, repo, ws, inputPath);
|
|
201
129
|
if (refType === 'unassigned') {
|
|
202
|
-
// External input that is unassigned - this task can never run
|
|
203
130
|
node.unresolvedCount++;
|
|
204
131
|
}
|
|
205
132
|
}
|
|
@@ -213,19 +140,16 @@ async function buildDependencyGraph(storage, repo, ws) {
|
|
|
213
140
|
/**
|
|
214
141
|
* Execute all tasks in a workspace according to the dependency graph.
|
|
215
142
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* Acquires an exclusive lock on the workspace for the duration of execution
|
|
221
|
-
* to prevent concurrent modifications. If options.lock is provided, uses that
|
|
222
|
-
* lock instead (caller is responsible for releasing it).
|
|
143
|
+
* Delegates to `LocalOrchestrator` which implements reactive fixpoint
|
|
144
|
+
* execution using step functions. After each task completes, input changes
|
|
145
|
+
* are detected and affected tasks are invalidated and re-executed.
|
|
223
146
|
*
|
|
224
147
|
* @param storage - Storage backend
|
|
225
|
-
* @param repo - Repository identifier
|
|
148
|
+
* @param repo - Repository identifier
|
|
226
149
|
* @param ws - Workspace name
|
|
227
150
|
* @param options - Execution options
|
|
228
151
|
* @returns Result of the dataflow execution
|
|
152
|
+
*
|
|
229
153
|
* @throws {WorkspaceLockError} If workspace is locked by another process
|
|
230
154
|
* @throws {WorkspaceNotFoundError} If workspace doesn't exist
|
|
231
155
|
* @throws {WorkspaceNotDeployedError} If workspace has no package deployed
|
|
@@ -233,494 +157,83 @@ async function buildDependencyGraph(storage, repo, ws) {
|
|
|
233
157
|
* @throws {DataflowError} If execution fails for other reasons
|
|
234
158
|
*/
|
|
235
159
|
export async function dataflowExecute(storage, repo, ws, options = {}) {
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
160
|
+
const { LocalOrchestrator } = await import('./dataflow/orchestrator/LocalOrchestrator.js');
|
|
161
|
+
const orchestrator = new LocalOrchestrator();
|
|
162
|
+
const taskResults = [];
|
|
163
|
+
const handle = await orchestrator.start(storage, repo, ws, {
|
|
164
|
+
concurrency: options.concurrency,
|
|
165
|
+
force: options.force,
|
|
166
|
+
filter: options.filter,
|
|
167
|
+
signal: options.signal,
|
|
168
|
+
lock: options.lock,
|
|
169
|
+
runner: options.runner,
|
|
170
|
+
onTaskStart: options.onTaskStart,
|
|
171
|
+
onTaskComplete: (result) => {
|
|
172
|
+
taskResults.push({
|
|
173
|
+
name: result.name,
|
|
174
|
+
cached: result.cached,
|
|
175
|
+
state: result.state,
|
|
176
|
+
error: result.error,
|
|
177
|
+
exitCode: result.exitCode,
|
|
178
|
+
duration: result.duration,
|
|
179
|
+
});
|
|
180
|
+
options.onTaskComplete?.({
|
|
181
|
+
name: result.name,
|
|
182
|
+
cached: result.cached,
|
|
183
|
+
state: result.state,
|
|
184
|
+
error: result.error,
|
|
185
|
+
exitCode: result.exitCode,
|
|
186
|
+
duration: result.duration,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
onStdout: options.onStdout,
|
|
190
|
+
onStderr: options.onStderr,
|
|
191
|
+
onInputChanged: options.onInputChanged,
|
|
192
|
+
onTaskInvalidated: options.onTaskInvalidated,
|
|
193
|
+
onTaskDeferred: options.onTaskDeferred,
|
|
194
|
+
});
|
|
195
|
+
const result = await orchestrator.wait(handle);
|
|
196
|
+
return {
|
|
197
|
+
success: result.success,
|
|
198
|
+
runId: result.runId,
|
|
199
|
+
executed: result.executed,
|
|
200
|
+
cached: result.cached,
|
|
201
|
+
failed: result.failed,
|
|
202
|
+
skipped: result.skipped,
|
|
203
|
+
reexecuted: result.reexecuted,
|
|
204
|
+
tasks: taskResults,
|
|
205
|
+
duration: result.duration,
|
|
206
|
+
};
|
|
252
207
|
}
|
|
253
208
|
/**
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* Returns a promise immediately without awaiting execution. The lock is
|
|
257
|
-
* released automatically when execution completes.
|
|
209
|
+
* Execute dataflow with an externally-held lock.
|
|
210
|
+
* The lock is released automatically when execution completes or fails.
|
|
258
211
|
*
|
|
259
212
|
* @param storage - Storage backend
|
|
260
|
-
* @param repo - Repository identifier
|
|
213
|
+
* @param repo - Repository identifier
|
|
261
214
|
* @param ws - Workspace name
|
|
262
215
|
* @param options - Execution options (lock must be provided)
|
|
263
216
|
* @returns Promise that resolves when execution completes
|
|
264
|
-
* @throws {WorkspaceNotFoundError} If workspace doesn't exist
|
|
265
|
-
* @throws {WorkspaceNotDeployedError} If workspace has no package deployed
|
|
266
|
-
* @throws {TaskNotFoundError} If filter specifies a task that doesn't exist
|
|
267
|
-
* @throws {DataflowError} If execution fails for other reasons
|
|
268
217
|
*/
|
|
269
|
-
export function dataflowStart(storage, repo, ws, options) {
|
|
270
|
-
return dataflowExecuteWithLock(storage, repo, ws, options)
|
|
271
|
-
.finally(() => options.lock.release());
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Internal: Execute dataflow with lock already held.
|
|
275
|
-
*/
|
|
276
|
-
async function dataflowExecuteWithLock(storage, repo, ws, options) {
|
|
277
|
-
const startTime = Date.now();
|
|
278
|
-
const startedAt = new Date();
|
|
279
|
-
const concurrency = options.concurrency ?? 4;
|
|
280
|
-
// Generate run ID for this execution
|
|
281
|
-
const runId = uuidv7();
|
|
282
|
-
let taskNodes;
|
|
283
|
-
let taskDependents;
|
|
284
|
-
let outputToTask;
|
|
285
|
-
let wsState;
|
|
218
|
+
export async function dataflowStart(storage, repo, ws, options) {
|
|
286
219
|
try {
|
|
287
|
-
|
|
288
|
-
wsState = await readWorkspaceState(storage, repo, ws);
|
|
289
|
-
// Build dependency graph
|
|
290
|
-
const graphResult = await buildDependencyGraph(storage, repo, ws);
|
|
291
|
-
taskNodes = graphResult.taskNodes;
|
|
292
|
-
taskDependents = graphResult.taskDependents;
|
|
293
|
-
outputToTask = graphResult.outputToTask;
|
|
294
|
-
}
|
|
295
|
-
catch (err) {
|
|
296
|
-
// Re-throw E3Errors as-is
|
|
297
|
-
if (err instanceof E3Error)
|
|
298
|
-
throw err;
|
|
299
|
-
// Wrap unexpected errors
|
|
300
|
-
throw new DataflowError(`Failed to build dependency graph: ${err instanceof Error ? err.message : err}`);
|
|
220
|
+
return await dataflowExecute(storage, repo, ws, options);
|
|
301
221
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
for (const oldRunId of allRunIds) {
|
|
305
|
-
await storage.refs.dataflowRunDelete(repo, ws, oldRunId);
|
|
306
|
-
}
|
|
307
|
-
// Initialize task execution records map
|
|
308
|
-
const taskExecutions = new Map();
|
|
309
|
-
// Create initial DataflowRun record
|
|
310
|
-
const initialRun = {
|
|
311
|
-
runId,
|
|
312
|
-
workspaceName: ws,
|
|
313
|
-
packageRef: `${wsState.packageName}@${wsState.packageVersion}`,
|
|
314
|
-
startedAt,
|
|
315
|
-
completedAt: variant('none', null),
|
|
316
|
-
status: variant('running', {}),
|
|
317
|
-
inputSnapshot: wsState.rootHash,
|
|
318
|
-
outputSnapshot: variant('none', null),
|
|
319
|
-
taskExecutions: taskExecutions,
|
|
320
|
-
summary: {
|
|
321
|
-
total: BigInt(taskNodes.size),
|
|
322
|
-
completed: 0n,
|
|
323
|
-
cached: 0n,
|
|
324
|
-
failed: 0n,
|
|
325
|
-
skipped: 0n,
|
|
326
|
-
},
|
|
327
|
-
};
|
|
328
|
-
// Write initial run record
|
|
329
|
-
await storage.refs.dataflowRunWrite(repo, ws, initialRun);
|
|
330
|
-
// Build DataflowGraph for use with decomposed building blocks
|
|
331
|
-
const dataflowGraph = {
|
|
332
|
-
tasks: Array.from(taskNodes.entries()).map(([taskName, node]) => {
|
|
333
|
-
const dependsOn = [];
|
|
334
|
-
for (const inputPath of node.inputPaths) {
|
|
335
|
-
const inputPathStr = pathToString(inputPath);
|
|
336
|
-
const producerTask = outputToTask.get(inputPathStr);
|
|
337
|
-
if (producerTask) {
|
|
338
|
-
dependsOn.push(producerTask);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
return {
|
|
342
|
-
name: taskName,
|
|
343
|
-
hash: node.hash,
|
|
344
|
-
inputs: node.inputPaths.map(pathToString),
|
|
345
|
-
output: pathToString(node.outputPath),
|
|
346
|
-
dependsOn,
|
|
347
|
-
};
|
|
348
|
-
}),
|
|
349
|
-
};
|
|
350
|
-
// Apply filter if specified
|
|
351
|
-
const filteredTaskNames = options.filter
|
|
352
|
-
? new Set([options.filter])
|
|
353
|
-
: null;
|
|
354
|
-
// Validate filter
|
|
355
|
-
if (filteredTaskNames && options.filter && !taskNodes.has(options.filter)) {
|
|
356
|
-
throw new TaskNotFoundError(options.filter);
|
|
357
|
-
}
|
|
358
|
-
// Track execution state
|
|
359
|
-
const results = [];
|
|
360
|
-
let executed = 0;
|
|
361
|
-
let cached = 0;
|
|
362
|
-
let failed = 0;
|
|
363
|
-
let skipped = 0;
|
|
364
|
-
let hasFailure = false;
|
|
365
|
-
let aborted = false;
|
|
366
|
-
// Check for abort signal
|
|
367
|
-
const checkAborted = () => {
|
|
368
|
-
if (options.signal?.aborted && !aborted) {
|
|
369
|
-
aborted = true;
|
|
370
|
-
}
|
|
371
|
-
return aborted;
|
|
372
|
-
};
|
|
373
|
-
// Mutex to serialize workspace state updates.
|
|
374
|
-
// When multiple tasks complete concurrently, their writes to the workspace
|
|
375
|
-
// must be serialized to prevent lost updates (read-modify-write race).
|
|
376
|
-
const workspaceUpdateMutex = new AsyncMutex();
|
|
377
|
-
// Ready queue: tasks with all dependencies resolved
|
|
378
|
-
const readyQueue = [];
|
|
379
|
-
const completed = new Set();
|
|
380
|
-
const inProgress = new Set();
|
|
381
|
-
const skippedTasks = new Set(); // Track skipped tasks separately for dataflowGetDependentsToSkip
|
|
382
|
-
// Initialize ready queue with tasks that have no unresolved dependencies
|
|
383
|
-
// and pass the filter (if any)
|
|
384
|
-
for (const [taskName, node] of taskNodes) {
|
|
385
|
-
if (node.unresolvedCount === 0) {
|
|
386
|
-
if (!filteredTaskNames || filteredTaskNames.has(taskName)) {
|
|
387
|
-
readyQueue.push(taskName);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
// Check if the task has a valid cached execution for current inputs
|
|
392
|
-
// Returns the output hash and executionId if cached, null if re-execution is needed
|
|
393
|
-
async function getCachedOutput(taskName) {
|
|
394
|
-
const node = taskNodes.get(taskName);
|
|
395
|
-
// Gather current input hashes
|
|
396
|
-
const currentInputHashes = [];
|
|
397
|
-
for (const inputPath of node.inputPaths) {
|
|
398
|
-
const { refType, hash } = await workspaceGetDatasetHash(storage, repo, ws, inputPath);
|
|
399
|
-
if (refType !== 'value' || hash === null) {
|
|
400
|
-
// Input not assigned, can't be cached
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
currentInputHashes.push(hash);
|
|
404
|
-
}
|
|
405
|
-
// Check if there's a cached execution for these inputs
|
|
406
|
-
const inHash = inputsHash(currentInputHashes);
|
|
407
|
-
const cachedOutputHash = await executionGetOutput(storage, repo, node.hash, inHash);
|
|
408
|
-
if (cachedOutputHash === null) {
|
|
409
|
-
// No cached execution for current inputs
|
|
410
|
-
return null;
|
|
411
|
-
}
|
|
412
|
-
// Get the latest execution status to retrieve the executionId
|
|
413
|
-
const latestStatus = await storage.refs.executionGetLatest(repo, node.hash, inHash);
|
|
414
|
-
if (!latestStatus || latestStatus.type !== 'success') {
|
|
415
|
-
// Latest execution wasn't a success
|
|
416
|
-
return null;
|
|
417
|
-
}
|
|
418
|
-
// Also verify the workspace output matches the cached output
|
|
419
|
-
// (in case the workspace was modified outside of execution)
|
|
420
|
-
const { refType, hash: wsOutputHash } = await workspaceGetDatasetHash(storage, repo, ws, node.outputPath);
|
|
421
|
-
if (refType !== 'value' || wsOutputHash !== cachedOutputHash) {
|
|
422
|
-
// Workspace output doesn't match cached output, need to re-execute
|
|
423
|
-
// (or update workspace with cached value)
|
|
424
|
-
return null;
|
|
425
|
-
}
|
|
426
|
-
return { outputHash: cachedOutputHash, executionId: latestStatus.value.executionId };
|
|
427
|
-
}
|
|
428
|
-
// Execute a single task (does NOT write to workspace - caller must do that)
|
|
429
|
-
async function executeTask(taskName) {
|
|
430
|
-
const node = taskNodes.get(taskName);
|
|
431
|
-
const taskStartTime = Date.now();
|
|
432
|
-
options.onTaskStart?.(taskName);
|
|
433
|
-
// Gather input hashes
|
|
434
|
-
const inputHashes = [];
|
|
435
|
-
for (const inputPath of node.inputPaths) {
|
|
436
|
-
const { refType, hash } = await workspaceGetDatasetHash(storage, repo, ws, inputPath);
|
|
437
|
-
if (refType !== 'value' || hash === null) {
|
|
438
|
-
// Input not available - should not happen if dependency tracking is correct
|
|
439
|
-
return {
|
|
440
|
-
name: taskName,
|
|
441
|
-
cached: false,
|
|
442
|
-
state: 'error',
|
|
443
|
-
error: `Input at ${pathToString(inputPath)} is not assigned (refType: ${refType})`,
|
|
444
|
-
duration: Date.now() - taskStartTime,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
inputHashes.push(hash);
|
|
448
|
-
}
|
|
449
|
-
// Execute the task using either the provided runner or direct taskExecute()
|
|
450
|
-
const execOptions = {
|
|
451
|
-
force: options.force,
|
|
452
|
-
signal: options.signal,
|
|
453
|
-
onStdout: options.onStdout ? (data) => options.onStdout(taskName, data) : undefined,
|
|
454
|
-
onStderr: options.onStderr ? (data) => options.onStderr(taskName, data) : undefined,
|
|
455
|
-
};
|
|
456
|
-
// Use provided runner if available, otherwise call taskExecute directly
|
|
457
|
-
const result = options.runner
|
|
458
|
-
? await options.runner.execute(storage, node.hash, inputHashes, execOptions)
|
|
459
|
-
: await taskExecute(storage, repo, node.hash, inputHashes, execOptions);
|
|
460
|
-
// Build task result (NOTE: workspace update happens later, in mutex-protected section)
|
|
461
|
-
const taskResult = {
|
|
462
|
-
name: taskName,
|
|
463
|
-
cached: result.cached,
|
|
464
|
-
executionId: result.executionId,
|
|
465
|
-
state: result.state,
|
|
466
|
-
duration: Date.now() - taskStartTime,
|
|
467
|
-
};
|
|
468
|
-
if (result.state === 'error') {
|
|
469
|
-
taskResult.error = result.error ?? undefined;
|
|
470
|
-
}
|
|
471
|
-
else if (result.state === 'failed') {
|
|
472
|
-
taskResult.exitCode = result.exitCode ?? undefined;
|
|
473
|
-
}
|
|
474
|
-
// Pass output hash to caller for workspace update (if successful)
|
|
475
|
-
if (result.state === 'success' && result.outputHash) {
|
|
476
|
-
taskResult.outputHash = result.outputHash;
|
|
477
|
-
}
|
|
478
|
-
return taskResult;
|
|
479
|
-
}
|
|
480
|
-
// Process dependents when a task completes
|
|
481
|
-
function notifyDependents(taskName) {
|
|
482
|
-
const dependents = taskDependents.get(taskName) ?? new Set();
|
|
483
|
-
for (const depName of dependents) {
|
|
484
|
-
if (completed.has(depName) || inProgress.has(depName))
|
|
485
|
-
continue;
|
|
486
|
-
// Skip dependents not in the filter
|
|
487
|
-
if (filteredTaskNames && !filteredTaskNames.has(depName))
|
|
488
|
-
continue;
|
|
489
|
-
const depNode = taskNodes.get(depName);
|
|
490
|
-
depNode.unresolvedCount--;
|
|
491
|
-
if (depNode.unresolvedCount === 0 && !readyQueue.includes(depName)) {
|
|
492
|
-
readyQueue.push(depName);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
// Mark dependents as skipped when a task fails.
|
|
497
|
-
// Uses dataflowGetDependentsToSkip to find all transitive dependents at once
|
|
498
|
-
// (shared with distributed execution in e3-aws).
|
|
499
|
-
function skipDependents(taskName) {
|
|
500
|
-
// Get all tasks to skip (excludes already completed, already skipped, and in-progress)
|
|
501
|
-
const toSkip = dataflowGetDependentsToSkip(dataflowGraph, taskName, completed, skippedTasks)
|
|
502
|
-
.filter(name => !inProgress.has(name)) // Also exclude in-progress tasks
|
|
503
|
-
.filter(name => !filteredTaskNames || filteredTaskNames.has(name)); // Apply filter
|
|
504
|
-
for (const depName of toSkip) {
|
|
505
|
-
completed.add(depName);
|
|
506
|
-
skippedTasks.add(depName);
|
|
507
|
-
skipped++;
|
|
508
|
-
results.push({
|
|
509
|
-
name: depName,
|
|
510
|
-
cached: false,
|
|
511
|
-
state: 'skipped',
|
|
512
|
-
duration: 0,
|
|
513
|
-
});
|
|
514
|
-
options.onTaskComplete?.({
|
|
515
|
-
name: depName,
|
|
516
|
-
cached: false,
|
|
517
|
-
state: 'skipped',
|
|
518
|
-
duration: 0,
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
// Main execution loop using a work-stealing approach
|
|
523
|
-
const runningPromises = new Map();
|
|
524
|
-
async function processQueue() {
|
|
525
|
-
while (true) {
|
|
526
|
-
// Check if we're done
|
|
527
|
-
if (readyQueue.length === 0 && runningPromises.size === 0) {
|
|
528
|
-
break;
|
|
529
|
-
}
|
|
530
|
-
// Launch tasks up to concurrency limit if no failure and not aborted
|
|
531
|
-
while (!hasFailure && !checkAborted() && readyQueue.length > 0 && runningPromises.size < concurrency) {
|
|
532
|
-
const taskName = readyQueue.shift();
|
|
533
|
-
if (completed.has(taskName) || inProgress.has(taskName))
|
|
534
|
-
continue;
|
|
535
|
-
// Check if there's a valid cached execution for current inputs
|
|
536
|
-
const cachedResult = await getCachedOutput(taskName);
|
|
537
|
-
if (cachedResult !== null && !options.force) {
|
|
538
|
-
// Valid cached execution exists for current inputs.
|
|
539
|
-
// No workspace write needed (output already matches), but we still
|
|
540
|
-
// need mutex protection for state updates to prevent races with
|
|
541
|
-
// concurrent task completions.
|
|
542
|
-
await workspaceUpdateMutex.runExclusive(() => {
|
|
543
|
-
completed.add(taskName);
|
|
544
|
-
cached++;
|
|
545
|
-
const result = {
|
|
546
|
-
name: taskName,
|
|
547
|
-
cached: true,
|
|
548
|
-
executionId: cachedResult.executionId,
|
|
549
|
-
state: 'success',
|
|
550
|
-
duration: 0,
|
|
551
|
-
};
|
|
552
|
-
results.push(result);
|
|
553
|
-
options.onTaskComplete?.(result);
|
|
554
|
-
notifyDependents(taskName);
|
|
555
|
-
// Track in taskExecutions map
|
|
556
|
-
taskExecutions.set(taskName, {
|
|
557
|
-
executionId: cachedResult.executionId,
|
|
558
|
-
cached: true,
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
inProgress.add(taskName);
|
|
564
|
-
const promise = (async () => {
|
|
565
|
-
try {
|
|
566
|
-
const result = await executeTask(taskName);
|
|
567
|
-
// Use mutex to serialize workspace updates and dependent notifications.
|
|
568
|
-
// This prevents race conditions where two tasks complete simultaneously,
|
|
569
|
-
// both read the same workspace state, and one overwrites the other's changes.
|
|
570
|
-
await workspaceUpdateMutex.runExclusive(async () => {
|
|
571
|
-
// Write output to workspace BEFORE notifying dependents
|
|
572
|
-
if (result.state === 'success' && result.outputHash) {
|
|
573
|
-
const node = taskNodes.get(taskName);
|
|
574
|
-
await workspaceSetDatasetByHash(storage, repo, ws, node.outputPath, result.outputHash);
|
|
575
|
-
}
|
|
576
|
-
// Now safe to update execution state and notify dependents
|
|
577
|
-
inProgress.delete(taskName);
|
|
578
|
-
completed.add(taskName);
|
|
579
|
-
results.push(result);
|
|
580
|
-
options.onTaskComplete?.(result);
|
|
581
|
-
if (result.state === 'success') {
|
|
582
|
-
if (result.cached) {
|
|
583
|
-
cached++;
|
|
584
|
-
}
|
|
585
|
-
else {
|
|
586
|
-
executed++;
|
|
587
|
-
}
|
|
588
|
-
notifyDependents(taskName);
|
|
589
|
-
// Track in taskExecutions map
|
|
590
|
-
if (result.executionId) {
|
|
591
|
-
taskExecutions.set(taskName, {
|
|
592
|
-
executionId: result.executionId,
|
|
593
|
-
cached: result.cached,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
failed++;
|
|
599
|
-
hasFailure = true;
|
|
600
|
-
skipDependents(taskName);
|
|
601
|
-
// Track failed execution too
|
|
602
|
-
if (result.executionId) {
|
|
603
|
-
taskExecutions.set(taskName, {
|
|
604
|
-
executionId: result.executionId,
|
|
605
|
-
cached: false,
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
finally {
|
|
612
|
-
runningPromises.delete(taskName);
|
|
613
|
-
}
|
|
614
|
-
})();
|
|
615
|
-
runningPromises.set(taskName, promise);
|
|
616
|
-
}
|
|
617
|
-
// Wait for at least one task to complete if we can't launch more
|
|
618
|
-
if (runningPromises.size > 0) {
|
|
619
|
-
await Promise.race(runningPromises.values());
|
|
620
|
-
}
|
|
621
|
-
else if (readyQueue.length === 0 || aborted) {
|
|
622
|
-
// No running tasks and either:
|
|
623
|
-
// - no ready tasks (unresolvable dependencies)
|
|
624
|
-
// - aborted (stop processing)
|
|
625
|
-
break;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
await processQueue();
|
|
630
|
-
// Wait for any remaining tasks
|
|
631
|
-
if (runningPromises.size > 0) {
|
|
632
|
-
await Promise.all(runningPromises.values());
|
|
633
|
-
}
|
|
634
|
-
// Check for abort one final time
|
|
635
|
-
checkAborted();
|
|
636
|
-
// If aborted, throw with partial results (also update run record)
|
|
637
|
-
if (aborted) {
|
|
638
|
-
const finalWsState = await readWorkspaceState(storage, repo, ws);
|
|
639
|
-
const cancelledRun = {
|
|
640
|
-
runId,
|
|
641
|
-
workspaceName: ws,
|
|
642
|
-
packageRef: `${wsState.packageName}@${wsState.packageVersion}`,
|
|
643
|
-
startedAt,
|
|
644
|
-
completedAt: variant('some', new Date()),
|
|
645
|
-
status: variant('cancelled', {}),
|
|
646
|
-
inputSnapshot: wsState.rootHash,
|
|
647
|
-
outputSnapshot: variant('some', finalWsState.rootHash),
|
|
648
|
-
taskExecutions,
|
|
649
|
-
summary: {
|
|
650
|
-
total: BigInt(taskNodes.size),
|
|
651
|
-
completed: BigInt(executed + cached),
|
|
652
|
-
cached: BigInt(cached),
|
|
653
|
-
failed: BigInt(failed),
|
|
654
|
-
skipped: BigInt(skipped),
|
|
655
|
-
},
|
|
656
|
-
};
|
|
657
|
-
await storage.refs.dataflowRunWrite(repo, ws, cancelledRun);
|
|
658
|
-
throw new DataflowAbortedError(results);
|
|
659
|
-
}
|
|
660
|
-
// Read final workspace state for output snapshot
|
|
661
|
-
const finalWsState = await readWorkspaceState(storage, repo, ws);
|
|
662
|
-
// Determine final status
|
|
663
|
-
let finalStatus;
|
|
664
|
-
if (hasFailure) {
|
|
665
|
-
// Find the failed task
|
|
666
|
-
const failedTask = results.find(r => r.state === 'failed' || r.state === 'error');
|
|
667
|
-
finalStatus = variant('failed', {
|
|
668
|
-
failedTask: failedTask?.name ?? 'unknown',
|
|
669
|
-
error: failedTask?.error ?? failedTask?.exitCode?.toString() ?? 'Task failed',
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
else {
|
|
673
|
-
finalStatus = variant('completed', {});
|
|
674
|
-
}
|
|
675
|
-
// Write final DataflowRun record
|
|
676
|
-
const finalRun = {
|
|
677
|
-
runId,
|
|
678
|
-
workspaceName: ws,
|
|
679
|
-
packageRef: `${wsState.packageName}@${wsState.packageVersion}`,
|
|
680
|
-
startedAt,
|
|
681
|
-
completedAt: variant('some', new Date()),
|
|
682
|
-
status: finalStatus,
|
|
683
|
-
inputSnapshot: wsState.rootHash,
|
|
684
|
-
outputSnapshot: variant('some', finalWsState.rootHash),
|
|
685
|
-
taskExecutions,
|
|
686
|
-
summary: {
|
|
687
|
-
total: BigInt(taskNodes.size),
|
|
688
|
-
completed: BigInt(executed + cached),
|
|
689
|
-
cached: BigInt(cached),
|
|
690
|
-
failed: BigInt(failed),
|
|
691
|
-
skipped: BigInt(skipped),
|
|
692
|
-
},
|
|
693
|
-
};
|
|
694
|
-
await storage.refs.dataflowRunWrite(repo, ws, finalRun);
|
|
695
|
-
// Update workspace state with currentRunId on success
|
|
696
|
-
if (!hasFailure) {
|
|
697
|
-
// Read, update, write workspace state
|
|
698
|
-
const currentState = await readWorkspaceState(storage, repo, ws);
|
|
699
|
-
const updatedState = {
|
|
700
|
-
...currentState,
|
|
701
|
-
currentRunId: variant('some', runId),
|
|
702
|
-
};
|
|
703
|
-
const encoder = encodeBeast2For(WorkspaceStateType);
|
|
704
|
-
await storage.refs.workspaceWrite(repo, ws, encoder(updatedState));
|
|
222
|
+
finally {
|
|
223
|
+
await options.lock.release();
|
|
705
224
|
}
|
|
706
|
-
return {
|
|
707
|
-
success: !hasFailure,
|
|
708
|
-
runId,
|
|
709
|
-
executed,
|
|
710
|
-
cached,
|
|
711
|
-
failed,
|
|
712
|
-
skipped,
|
|
713
|
-
tasks: results,
|
|
714
|
-
duration: Date.now() - startTime,
|
|
715
|
-
};
|
|
716
225
|
}
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// Graph Queries (shared between local and cloud execution)
|
|
228
|
+
// =============================================================================
|
|
717
229
|
/**
|
|
718
230
|
* Get the dependency graph for a workspace (for visualization/debugging).
|
|
719
231
|
*
|
|
720
232
|
* @param storage - Storage backend
|
|
721
|
-
* @param repo - Repository identifier
|
|
233
|
+
* @param repo - Repository identifier
|
|
722
234
|
* @param ws - Workspace name
|
|
723
235
|
* @returns Graph information
|
|
236
|
+
*
|
|
724
237
|
* @throws {WorkspaceNotFoundError} If workspace doesn't exist
|
|
725
238
|
* @throws {WorkspaceNotDeployedError} If workspace has no package deployed
|
|
726
239
|
* @throws {DataflowError} If graph building fails for other reasons
|
|
@@ -758,33 +271,60 @@ export async function dataflowGetGraph(storage, repo, ws) {
|
|
|
758
271
|
}
|
|
759
272
|
return { tasks };
|
|
760
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Find all tasks affected by input changes (transitive dependents).
|
|
276
|
+
* An affected task is one whose output could change due to the input change.
|
|
277
|
+
*
|
|
278
|
+
* @param graph - The dependency graph
|
|
279
|
+
* @param changes - Array of changed input paths
|
|
280
|
+
* @returns Array of affected task names
|
|
281
|
+
*/
|
|
282
|
+
export function findAffectedTasks(graph, changes) {
|
|
283
|
+
const changedPaths = new Set(changes.map(c => c.path));
|
|
284
|
+
const affected = new Set();
|
|
285
|
+
const queue = [];
|
|
286
|
+
// Build forward dep map: task name → tasks that depend on its output
|
|
287
|
+
const taskToDependents = new Map();
|
|
288
|
+
for (const task of graph.tasks) {
|
|
289
|
+
for (const dep of task.dependsOn) {
|
|
290
|
+
if (!taskToDependents.has(dep))
|
|
291
|
+
taskToDependents.set(dep, []);
|
|
292
|
+
taskToDependents.get(dep).push(task.name);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Seed: tasks that directly read a changed input
|
|
296
|
+
for (const task of graph.tasks) {
|
|
297
|
+
if (task.inputs.some(inp => changedPaths.has(inp))) {
|
|
298
|
+
queue.push(task.name);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// BFS through dependency graph
|
|
302
|
+
while (queue.length > 0) {
|
|
303
|
+
const name = queue.shift();
|
|
304
|
+
if (affected.has(name))
|
|
305
|
+
continue;
|
|
306
|
+
affected.add(name);
|
|
307
|
+
for (const dep of taskToDependents.get(name) ?? []) {
|
|
308
|
+
queue.push(dep);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return Array.from(affected);
|
|
312
|
+
}
|
|
761
313
|
/**
|
|
762
314
|
* Get tasks that are ready to execute given the set of completed tasks.
|
|
763
315
|
*
|
|
764
316
|
* A task is ready when all tasks it depends on have completed.
|
|
765
|
-
* This is useful for distributed execution (e.g., AWS Step Functions)
|
|
766
|
-
* where a coordinator needs to determine which tasks can run next.
|
|
767
317
|
*
|
|
768
318
|
* @param graph - The dependency graph from dataflowGetGraph
|
|
769
319
|
* @param completedTasks - Set of task names that have completed
|
|
770
320
|
* @returns Array of task names that are ready to execute
|
|
771
|
-
*
|
|
772
|
-
* @example
|
|
773
|
-
* ```typescript
|
|
774
|
-
* const graph = await dataflowGetGraph(storage, repo, 'production');
|
|
775
|
-
* const ready = dataflowGetReadyTasks(graph, new Set()); // Initial ready tasks
|
|
776
|
-
* // Execute ready[0]...
|
|
777
|
-
* const nextReady = dataflowGetReadyTasks(graph, new Set([ready[0]]));
|
|
778
|
-
* ```
|
|
779
321
|
*/
|
|
780
322
|
export function dataflowGetReadyTasks(graph, completedTasks) {
|
|
781
323
|
const ready = [];
|
|
782
324
|
for (const task of graph.tasks) {
|
|
783
|
-
// Skip already completed tasks
|
|
784
325
|
if (completedTasks.has(task.name)) {
|
|
785
326
|
continue;
|
|
786
327
|
}
|
|
787
|
-
// Check if all dependencies are satisfied
|
|
788
328
|
const allDepsCompleted = task.dependsOn.every(dep => completedTasks.has(dep));
|
|
789
329
|
if (allDepsCompleted) {
|
|
790
330
|
ready.push(task.name);
|
|
@@ -795,24 +335,11 @@ export function dataflowGetReadyTasks(graph, completedTasks) {
|
|
|
795
335
|
/**
|
|
796
336
|
* Check if a task execution is cached for the given inputs.
|
|
797
337
|
*
|
|
798
|
-
* This is useful for distributed execution where a Lambda handler needs
|
|
799
|
-
* to check if a task can be skipped before spawning execution.
|
|
800
|
-
*
|
|
801
338
|
* @param storage - Storage backend
|
|
802
339
|
* @param repo - Repository path
|
|
803
340
|
* @param taskHash - Hash of the TaskObject
|
|
804
341
|
* @param inputHashes - Array of input dataset hashes (in order)
|
|
805
342
|
* @returns Output hash if cached, null if execution needed
|
|
806
|
-
*
|
|
807
|
-
* @example
|
|
808
|
-
* ```typescript
|
|
809
|
-
* const outputHash = await dataflowCheckCache(storage, repo, taskHash, inputHashes);
|
|
810
|
-
* if (outputHash) {
|
|
811
|
-
* // Task is cached, use outputHash directly
|
|
812
|
-
* } else {
|
|
813
|
-
* // Need to execute task
|
|
814
|
-
* }
|
|
815
|
-
* ```
|
|
816
343
|
*/
|
|
817
344
|
export async function dataflowCheckCache(storage, repo, taskHash, inputHashes) {
|
|
818
345
|
const inHash = inputsHash(inputHashes);
|
|
@@ -821,29 +348,16 @@ export async function dataflowCheckCache(storage, repo, taskHash, inputHashes) {
|
|
|
821
348
|
/**
|
|
822
349
|
* Find tasks that should be skipped when a task fails.
|
|
823
350
|
*
|
|
824
|
-
* Returns all tasks that transitively depend on the failed task
|
|
825
|
-
*
|
|
826
|
-
* or already skipped tasks.
|
|
827
|
-
*
|
|
828
|
-
* This is useful for distributed execution where the coordinator
|
|
829
|
-
* needs to mark downstream tasks as skipped after a failure.
|
|
351
|
+
* Returns all tasks that transitively depend on the failed task,
|
|
352
|
+
* excluding already completed or already skipped tasks.
|
|
830
353
|
*
|
|
831
354
|
* @param graph - The dependency graph from dataflowGetGraph
|
|
832
355
|
* @param failedTask - Name of the task that failed
|
|
833
|
-
* @param completedTasks - Set of task names already completed
|
|
834
|
-
* @param skippedTasks - Set of task names already skipped
|
|
356
|
+
* @param completedTasks - Set of task names already completed
|
|
357
|
+
* @param skippedTasks - Set of task names already skipped
|
|
835
358
|
* @returns Array of task names that should be skipped
|
|
836
|
-
*
|
|
837
|
-
* @example
|
|
838
|
-
* ```typescript
|
|
839
|
-
* const graph = await dataflowGetGraph(storage, repo, 'production');
|
|
840
|
-
* // Task 'etl' failed...
|
|
841
|
-
* const toSkip = dataflowGetDependentsToSkip(graph, 'etl', completed, skipped);
|
|
842
|
-
* // toSkip might be ['transform', 'aggregate', 'report'] - all downstream tasks
|
|
843
|
-
* ```
|
|
844
359
|
*/
|
|
845
360
|
export function dataflowGetDependentsToSkip(graph, failedTask, completedTasks, skippedTasks) {
|
|
846
|
-
// Build reverse dependency map: task -> tasks that depend on it
|
|
847
361
|
const dependents = new Map();
|
|
848
362
|
for (const task of graph.tasks) {
|
|
849
363
|
dependents.set(task.name, []);
|
|
@@ -853,7 +367,6 @@ export function dataflowGetDependentsToSkip(graph, failedTask, completedTasks, s
|
|
|
853
367
|
dependents.get(dep)?.push(task.name);
|
|
854
368
|
}
|
|
855
369
|
}
|
|
856
|
-
// BFS to find all transitive dependents
|
|
857
370
|
const toSkip = [];
|
|
858
371
|
const visited = new Set();
|
|
859
372
|
const queue = [failedTask];
|
|
@@ -861,21 +374,15 @@ export function dataflowGetDependentsToSkip(graph, failedTask, completedTasks, s
|
|
|
861
374
|
const current = queue.shift();
|
|
862
375
|
const deps = dependents.get(current) ?? [];
|
|
863
376
|
for (const dep of deps) {
|
|
864
|
-
|
|
865
|
-
if (visited.has(dep)) {
|
|
377
|
+
if (visited.has(dep))
|
|
866
378
|
continue;
|
|
867
|
-
}
|
|
868
379
|
visited.add(dep);
|
|
869
|
-
|
|
870
|
-
if (completedTasks.has(dep)) {
|
|
380
|
+
if (completedTasks.has(dep))
|
|
871
381
|
continue;
|
|
872
|
-
}
|
|
873
|
-
// If already skipped, still explore dependents but don't add to result again
|
|
874
382
|
if (skippedTasks.has(dep)) {
|
|
875
383
|
queue.push(dep);
|
|
876
384
|
continue;
|
|
877
385
|
}
|
|
878
|
-
// New task to skip
|
|
879
386
|
toSkip.push(dep);
|
|
880
387
|
queue.push(dep);
|
|
881
388
|
}
|
|
@@ -885,32 +392,15 @@ export function dataflowGetDependentsToSkip(graph, failedTask, completedTasks, s
|
|
|
885
392
|
/**
|
|
886
393
|
* Resolve input hashes for a task from current workspace state.
|
|
887
394
|
*
|
|
888
|
-
* Returns an array of hashes in the same order as the task's inputs.
|
|
889
|
-
* If any input is unassigned, returns null for that position.
|
|
890
|
-
*
|
|
891
|
-
* This is useful for distributed execution where the input hashes
|
|
892
|
-
* need to be resolved before checking cache or executing.
|
|
893
|
-
*
|
|
894
395
|
* @param storage - Storage backend
|
|
895
396
|
* @param repo - Repository path
|
|
896
397
|
* @param ws - Workspace name
|
|
897
|
-
* @param task - Task info from the graph
|
|
398
|
+
* @param task - Task info from the graph
|
|
898
399
|
* @returns Array of hashes (null if input is unassigned)
|
|
899
|
-
*
|
|
900
|
-
* @example
|
|
901
|
-
* ```typescript
|
|
902
|
-
* const graph = await dataflowGetGraph(storage, repo, 'production');
|
|
903
|
-
* const task = graph.tasks.find(t => t.name === 'etl')!;
|
|
904
|
-
* const inputHashes = await dataflowResolveInputHashes(storage, repo, 'production', task);
|
|
905
|
-
* if (!inputHashes.includes(null)) {
|
|
906
|
-
* const cached = await dataflowCheckCache(storage, repo, task.hash, inputHashes);
|
|
907
|
-
* }
|
|
908
|
-
* ```
|
|
909
400
|
*/
|
|
910
401
|
export async function dataflowResolveInputHashes(storage, repo, ws, task) {
|
|
911
402
|
const hashes = [];
|
|
912
403
|
for (const inputPathStr of task.inputs) {
|
|
913
|
-
// Parse the keypath string back to TreePath
|
|
914
404
|
const inputPath = parsePathString(inputPathStr);
|
|
915
405
|
const { refType, hash } = await workspaceGetDatasetHash(storage, repo, ws, inputPath);
|
|
916
406
|
if (refType === 'value' && hash !== null) {
|