@elaraai/e3-core 0.0.2-beta.36 → 0.0.2-beta.37

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.
Files changed (78) hide show
  1. package/dist/src/dataflow/api-compat.d.ts.map +1 -1
  2. package/dist/src/dataflow/api-compat.js +6 -1
  3. package/dist/src/dataflow/api-compat.js.map +1 -1
  4. package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts +22 -4
  5. package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts.map +1 -1
  6. package/dist/src/dataflow/orchestrator/LocalOrchestrator.js +353 -79
  7. package/dist/src/dataflow/orchestrator/LocalOrchestrator.js.map +1 -1
  8. package/dist/src/dataflow/orchestrator/interfaces.d.ts +6 -0
  9. package/dist/src/dataflow/orchestrator/interfaces.d.ts.map +1 -1
  10. package/dist/src/dataflow/orchestrator/interfaces.js +1 -0
  11. package/dist/src/dataflow/orchestrator/interfaces.js.map +1 -1
  12. package/dist/src/dataflow/steps.d.ts +74 -28
  13. package/dist/src/dataflow/steps.d.ts.map +1 -1
  14. package/dist/src/dataflow/steps.js +221 -42
  15. package/dist/src/dataflow/steps.js.map +1 -1
  16. package/dist/src/dataflow/types.d.ts +13 -2
  17. package/dist/src/dataflow/types.d.ts.map +1 -1
  18. package/dist/src/dataflow.d.ts +37 -95
  19. package/dist/src/dataflow.d.ts.map +1 -1
  20. package/dist/src/dataflow.js +121 -631
  21. package/dist/src/dataflow.js.map +1 -1
  22. package/dist/src/dataset-refs.d.ts +124 -0
  23. package/dist/src/dataset-refs.d.ts.map +1 -0
  24. package/dist/src/dataset-refs.js +319 -0
  25. package/dist/src/dataset-refs.js.map +1 -0
  26. package/dist/src/execution/MockTaskRunner.d.ts +1 -1
  27. package/dist/src/execution/MockTaskRunner.d.ts.map +1 -1
  28. package/dist/src/execution/MockTaskRunner.js +1 -2
  29. package/dist/src/execution/MockTaskRunner.js.map +1 -1
  30. package/dist/src/index.d.ts +5 -4
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +6 -4
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/packages.d.ts.map +1 -1
  35. package/dist/src/packages.js +20 -7
  36. package/dist/src/packages.js.map +1 -1
  37. package/dist/src/storage/in-memory/InMemoryStorage.d.ts +26 -4
  38. package/dist/src/storage/in-memory/InMemoryStorage.d.ts.map +1 -1
  39. package/dist/src/storage/in-memory/InMemoryStorage.js +104 -21
  40. package/dist/src/storage/in-memory/InMemoryStorage.js.map +1 -1
  41. package/dist/src/storage/index.d.ts +2 -2
  42. package/dist/src/storage/index.d.ts.map +1 -1
  43. package/dist/src/storage/index.js +1 -1
  44. package/dist/src/storage/index.js.map +1 -1
  45. package/dist/src/storage/interfaces.d.ts +52 -1
  46. package/dist/src/storage/interfaces.d.ts.map +1 -1
  47. package/dist/src/storage/local/LocalBackend.d.ts +3 -1
  48. package/dist/src/storage/local/LocalBackend.d.ts.map +1 -1
  49. package/dist/src/storage/local/LocalBackend.js +5 -1
  50. package/dist/src/storage/local/LocalBackend.js.map +1 -1
  51. package/dist/src/storage/local/LocalDatasetRefStore.d.ts +22 -0
  52. package/dist/src/storage/local/LocalDatasetRefStore.d.ts.map +1 -0
  53. package/dist/src/storage/local/LocalDatasetRefStore.js +118 -0
  54. package/dist/src/storage/local/LocalDatasetRefStore.js.map +1 -0
  55. package/dist/src/storage/local/LocalLockService.d.ts +6 -0
  56. package/dist/src/storage/local/LocalLockService.d.ts.map +1 -1
  57. package/dist/src/storage/local/LocalLockService.js +17 -4
  58. package/dist/src/storage/local/LocalLockService.js.map +1 -1
  59. package/dist/src/storage/local/LocalRepoStore.d.ts +4 -2
  60. package/dist/src/storage/local/LocalRepoStore.d.ts.map +1 -1
  61. package/dist/src/storage/local/LocalRepoStore.js +14 -2
  62. package/dist/src/storage/local/LocalRepoStore.js.map +1 -1
  63. package/dist/src/storage/local/gc.d.ts.map +1 -1
  64. package/dist/src/storage/local/gc.js +8 -1
  65. package/dist/src/storage/local/gc.js.map +1 -1
  66. package/dist/src/storage/local/index.d.ts +1 -0
  67. package/dist/src/storage/local/index.d.ts.map +1 -1
  68. package/dist/src/storage/local/index.js +1 -0
  69. package/dist/src/storage/local/index.js.map +1 -1
  70. package/dist/src/trees.d.ts +35 -43
  71. package/dist/src/trees.d.ts.map +1 -1
  72. package/dist/src/trees.js +228 -449
  73. package/dist/src/trees.js.map +1 -1
  74. package/dist/src/workspaces.d.ts +6 -27
  75. package/dist/src/workspaces.d.ts.map +1 -1
  76. package/dist/src/workspaces.js +42 -55
  77. package/dist/src/workspaces.js.map +1 -1
  78. package/package.json +1 -1
@@ -5,29 +5,19 @@
5
5
  /**
6
6
  * Dataflow execution for e3 workspaces.
7
7
  *
8
- * Executes tasks in a workspace based on their dependency graph. Tasks are
9
- * executed in parallel where possible, respecting a concurrency limit.
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 model is event-driven with a work queue:
12
- * 1. Build dependency graph from tasks (input paths -> task -> output path)
13
- * 2. Compute reverse dependencies (which tasks depend on each output)
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, encodeBeast2For, variant } from '@elaraai/east';
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 { uuidv7 } from './uuid.js';
28
- import { taskExecute } from './execution/LocalTaskRunner.js';
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(); // output path -> task name
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, // Will be computed below
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
- * Tasks are executed in parallel where dependencies allow, respecting
217
- * the concurrency limit. On failure, no new tasks are launched but
218
- * running tasks are allowed to complete.
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 (for local storage, the path to e3 repository directory)
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
- // Acquire lock if not provided externally
237
- const externalLock = options.lock;
238
- const lock = externalLock ?? await storage.locks.acquire(repo, ws, variant('dataflow', null));
239
- if (!lock) {
240
- // Lock couldn't be acquired - the LockService returns null instead of throwing
241
- throw new WorkspaceLockError(ws);
242
- }
243
- try {
244
- return await dataflowExecuteWithLock(storage, repo, ws, options);
245
- }
246
- finally {
247
- // Only release the lock if we acquired it internally
248
- if (!externalLock) {
249
- await lock.release();
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
- * Start dataflow execution in the background (non-blocking).
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 (for local storage, the path to e3 repository directory)
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
- // Read workspace state for run tracking
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
- // Clean up all previous runs (we hold the lock, so no concurrent runs)
303
- const allRunIds = await storage.refs.dataflowRunList(repo, ws);
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 (for local storage, the path to e3 repository directory)
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
- * (directly or through other tasks), excluding already completed
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 (won't be skipped)
834
- * @param skippedTasks - Set of task names already skipped (won't be returned again)
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
- // Skip if already processed
865
- if (visited.has(dep)) {
377
+ if (visited.has(dep))
866
378
  continue;
867
- }
868
379
  visited.add(dep);
869
- // Skip if already completed (no need to explore further - completed tasks break the chain)
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 (needs inputs array)
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) {