@elaraai/e3-core 0.0.2-beta.8 → 1.0.0
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/LICENSE.md +4 -0
- package/README.md +74 -35
- package/dist/src/dataflow/api-compat.d.ts +90 -0
- package/dist/src/dataflow/api-compat.d.ts.map +1 -0
- package/dist/src/dataflow/api-compat.js +139 -0
- package/dist/src/dataflow/api-compat.js.map +1 -0
- package/dist/src/dataflow/api-compat.spec.d.ts +6 -0
- package/dist/src/dataflow/api-compat.spec.d.ts.map +1 -0
- package/dist/src/dataflow/api-compat.spec.js +182 -0
- package/dist/src/dataflow/api-compat.spec.js.map +1 -0
- package/dist/src/dataflow/index.d.ts +18 -0
- package/dist/src/dataflow/index.d.ts.map +1 -0
- package/dist/src/dataflow/index.js +23 -0
- package/dist/src/dataflow/index.js.map +1 -0
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts +76 -0
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts.map +1 -0
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.js +729 -0
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.js.map +1 -0
- package/dist/src/dataflow/orchestrator/index.d.ts +12 -0
- package/dist/src/dataflow/orchestrator/index.d.ts.map +1 -0
- package/dist/src/dataflow/orchestrator/index.js +12 -0
- package/dist/src/dataflow/orchestrator/index.js.map +1 -0
- package/dist/src/dataflow/orchestrator/interfaces.d.ts +163 -0
- package/dist/src/dataflow/orchestrator/interfaces.d.ts.map +1 -0
- package/dist/src/dataflow/orchestrator/interfaces.js +52 -0
- package/dist/src/dataflow/orchestrator/interfaces.js.map +1 -0
- package/dist/src/dataflow/state-store/FileStateStore.d.ts +67 -0
- package/dist/src/dataflow/state-store/FileStateStore.d.ts.map +1 -0
- package/dist/src/dataflow/state-store/FileStateStore.js +300 -0
- package/dist/src/dataflow/state-store/FileStateStore.js.map +1 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.d.ts +42 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.d.ts.map +1 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.js +229 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.js.map +1 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.spec.d.ts +6 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.spec.d.ts.map +1 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.spec.js +114 -0
- package/dist/src/dataflow/state-store/InMemoryStateStore.spec.js.map +1 -0
- package/dist/src/dataflow/state-store/index.d.ts +13 -0
- package/dist/src/dataflow/state-store/index.d.ts.map +1 -0
- package/dist/src/dataflow/state-store/index.js +13 -0
- package/dist/src/dataflow/state-store/index.js.map +1 -0
- package/dist/src/dataflow/state-store/interfaces.d.ts +159 -0
- package/dist/src/dataflow/state-store/interfaces.d.ts.map +1 -0
- package/dist/src/dataflow/state-store/interfaces.js +6 -0
- package/dist/src/dataflow/state-store/interfaces.js.map +1 -0
- package/dist/src/dataflow/steps.d.ts +222 -0
- package/dist/src/dataflow/steps.d.ts.map +1 -0
- package/dist/src/dataflow/steps.js +707 -0
- package/dist/src/dataflow/steps.js.map +1 -0
- package/dist/src/dataflow/steps.spec.d.ts +6 -0
- package/dist/src/dataflow/steps.spec.d.ts.map +1 -0
- package/dist/src/dataflow/steps.spec.js +343 -0
- package/dist/src/dataflow/steps.spec.js.map +1 -0
- package/dist/src/dataflow/types.d.ts +127 -0
- package/dist/src/dataflow/types.d.ts.map +1 -0
- package/dist/src/dataflow/types.js +7 -0
- package/dist/src/dataflow/types.js.map +1 -0
- package/dist/src/dataflow-orchestration.spec.d.ts +6 -0
- package/dist/src/dataflow-orchestration.spec.d.ts.map +1 -0
- package/dist/src/dataflow-orchestration.spec.js +1025 -0
- package/dist/src/dataflow-orchestration.spec.js.map +1 -0
- package/dist/src/dataflow.d.ts +113 -38
- package/dist/src/dataflow.d.ts.map +1 -1
- package/dist/src/dataflow.js +269 -416
- package/dist/src/dataflow.js.map +1 -1
- package/dist/src/dataflow.spec.d.ts +6 -0
- package/dist/src/dataflow.spec.d.ts.map +1 -0
- package/dist/src/dataflow.spec.js +663 -0
- package/dist/src/dataflow.spec.js.map +1 -0
- 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/errors.d.ts +39 -9
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +51 -8
- package/dist/src/errors.js.map +1 -1
- package/dist/src/errors.spec.d.ts +6 -0
- package/dist/src/errors.spec.d.ts.map +1 -0
- package/dist/src/errors.spec.js +276 -0
- package/dist/src/errors.spec.js.map +1 -0
- package/dist/src/execution/LocalTaskRunner.d.ts +73 -0
- package/dist/src/execution/LocalTaskRunner.d.ts.map +1 -0
- package/dist/src/execution/LocalTaskRunner.js +399 -0
- package/dist/src/execution/LocalTaskRunner.js.map +1 -0
- package/dist/src/execution/MockTaskRunner.d.ts +49 -0
- package/dist/src/execution/MockTaskRunner.d.ts.map +1 -0
- package/dist/src/execution/MockTaskRunner.js +54 -0
- package/dist/src/execution/MockTaskRunner.js.map +1 -0
- package/dist/src/execution/index.d.ts +16 -0
- package/dist/src/execution/index.d.ts.map +1 -0
- package/dist/src/execution/index.js +8 -0
- package/dist/src/execution/index.js.map +1 -0
- package/dist/src/execution/interfaces.d.ts +246 -0
- package/dist/src/execution/interfaces.d.ts.map +1 -0
- package/dist/src/execution/interfaces.js +6 -0
- package/dist/src/execution/interfaces.js.map +1 -0
- package/dist/src/execution/processHelpers.d.ts +20 -0
- package/dist/src/execution/processHelpers.d.ts.map +1 -0
- package/dist/src/execution/processHelpers.js +62 -0
- package/dist/src/execution/processHelpers.js.map +1 -0
- package/dist/src/executions.d.ts +71 -104
- package/dist/src/executions.d.ts.map +1 -1
- package/dist/src/executions.js +113 -481
- package/dist/src/executions.js.map +1 -1
- package/dist/src/executions.spec.d.ts +6 -0
- package/dist/src/executions.spec.d.ts.map +1 -0
- package/dist/src/executions.spec.js +387 -0
- package/dist/src/executions.spec.js.map +1 -0
- package/dist/src/formats.d.ts +18 -2
- package/dist/src/formats.d.ts.map +1 -1
- package/dist/src/formats.js +34 -2
- package/dist/src/formats.js.map +1 -1
- package/dist/src/gc.spec.d.ts +6 -0
- package/dist/src/gc.spec.d.ts.map +1 -0
- package/dist/src/gc.spec.js +512 -0
- package/dist/src/gc.spec.js.map +1 -0
- package/dist/src/index.d.ts +20 -10
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +48 -18
- package/dist/src/index.js.map +1 -1
- package/dist/src/objects.d.ts +7 -53
- package/dist/src/objects.d.ts.map +1 -1
- package/dist/src/objects.js +13 -232
- package/dist/src/objects.js.map +1 -1
- package/dist/src/objects.spec.d.ts +6 -0
- package/dist/src/objects.spec.d.ts.map +1 -0
- package/dist/src/objects.spec.js +247 -0
- package/dist/src/objects.spec.js.map +1 -0
- package/dist/src/packages.d.ts +41 -14
- package/dist/src/packages.d.ts.map +1 -1
- package/dist/src/packages.js +151 -89
- package/dist/src/packages.js.map +1 -1
- package/dist/src/packages.spec.d.ts +6 -0
- package/dist/src/packages.spec.d.ts.map +1 -0
- package/dist/src/packages.spec.js +324 -0
- package/dist/src/packages.spec.js.map +1 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.d.ts +35 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.d.ts.map +1 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.js +107 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.js.map +1 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.spec.d.ts +6 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.spec.d.ts.map +1 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.spec.js +187 -0
- package/dist/src/storage/in-memory/InMemoryRepoStore.spec.js.map +1 -0
- package/dist/src/storage/in-memory/InMemoryStorage.d.ts +139 -0
- package/dist/src/storage/in-memory/InMemoryStorage.d.ts.map +1 -0
- package/dist/src/storage/in-memory/InMemoryStorage.js +439 -0
- package/dist/src/storage/in-memory/InMemoryStorage.js.map +1 -0
- package/dist/src/storage/in-memory/index.d.ts +12 -0
- package/dist/src/storage/in-memory/index.d.ts.map +1 -0
- package/dist/src/storage/in-memory/index.js +12 -0
- package/dist/src/storage/in-memory/index.js.map +1 -0
- package/dist/src/storage/index.d.ts +18 -0
- package/dist/src/storage/index.d.ts.map +1 -0
- package/dist/src/storage/index.js +10 -0
- package/dist/src/storage/index.js.map +1 -0
- package/dist/src/storage/interfaces.d.ts +581 -0
- package/dist/src/storage/interfaces.d.ts.map +1 -0
- package/dist/src/storage/interfaces.js +6 -0
- package/dist/src/storage/interfaces.js.map +1 -0
- package/dist/src/storage/local/LocalBackend.d.ts +56 -0
- package/dist/src/storage/local/LocalBackend.d.ts.map +1 -0
- package/dist/src/storage/local/LocalBackend.js +145 -0
- package/dist/src/storage/local/LocalBackend.js.map +1 -0
- 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 +111 -0
- package/dist/src/storage/local/LocalLockService.d.ts.map +1 -0
- package/dist/src/storage/local/LocalLockService.js +364 -0
- package/dist/src/storage/local/LocalLockService.js.map +1 -0
- package/dist/src/storage/local/LocalLockService.spec.d.ts +6 -0
- package/dist/src/storage/local/LocalLockService.spec.d.ts.map +1 -0
- package/dist/src/storage/local/LocalLockService.spec.js +148 -0
- package/dist/src/storage/local/LocalLockService.spec.js.map +1 -0
- package/dist/src/storage/local/LocalLogStore.d.ts +23 -0
- package/dist/src/storage/local/LocalLogStore.d.ts.map +1 -0
- package/dist/src/storage/local/LocalLogStore.js +66 -0
- package/dist/src/storage/local/LocalLogStore.js.map +1 -0
- package/dist/src/storage/local/LocalObjectStore.d.ts +55 -0
- package/dist/src/storage/local/LocalObjectStore.d.ts.map +1 -0
- package/dist/src/storage/local/LocalObjectStore.js +300 -0
- package/dist/src/storage/local/LocalObjectStore.js.map +1 -0
- package/dist/src/storage/local/LocalRefStore.d.ts +50 -0
- package/dist/src/storage/local/LocalRefStore.d.ts.map +1 -0
- package/dist/src/storage/local/LocalRefStore.js +337 -0
- package/dist/src/storage/local/LocalRefStore.js.map +1 -0
- package/dist/src/storage/local/LocalRepoStore.d.ts +55 -0
- package/dist/src/storage/local/LocalRepoStore.d.ts.map +1 -0
- package/dist/src/storage/local/LocalRepoStore.js +365 -0
- package/dist/src/storage/local/LocalRepoStore.js.map +1 -0
- package/dist/src/storage/local/LocalRepoStore.spec.d.ts +6 -0
- package/dist/src/storage/local/LocalRepoStore.spec.d.ts.map +1 -0
- package/dist/src/storage/local/LocalRepoStore.spec.js +255 -0
- package/dist/src/storage/local/LocalRepoStore.spec.js.map +1 -0
- package/dist/src/storage/local/gc.d.ts +92 -0
- package/dist/src/storage/local/gc.d.ts.map +1 -0
- package/dist/src/storage/local/gc.js +377 -0
- package/dist/src/storage/local/gc.js.map +1 -0
- package/dist/src/storage/local/index.d.ts +18 -0
- package/dist/src/storage/local/index.d.ts.map +1 -0
- package/dist/src/storage/local/index.js +18 -0
- package/dist/src/storage/local/index.js.map +1 -0
- package/dist/src/storage/local/localHelpers.d.ts +25 -0
- package/dist/src/storage/local/localHelpers.d.ts.map +1 -0
- package/dist/src/storage/local/localHelpers.js +69 -0
- package/dist/src/storage/local/localHelpers.js.map +1 -0
- package/dist/src/{repository.d.ts → storage/local/repository.d.ts} +8 -4
- package/dist/src/storage/local/repository.d.ts.map +1 -0
- package/dist/src/{repository.js → storage/local/repository.js} +31 -29
- package/dist/src/storage/local/repository.js.map +1 -0
- package/dist/src/storage/local/repository.spec.d.ts +6 -0
- package/dist/src/storage/local/repository.spec.d.ts.map +1 -0
- package/dist/src/storage/local/repository.spec.js +186 -0
- package/dist/src/storage/local/repository.spec.js.map +1 -0
- package/dist/src/tasks.d.ts +16 -10
- package/dist/src/tasks.d.ts.map +1 -1
- package/dist/src/tasks.js +35 -41
- package/dist/src/tasks.js.map +1 -1
- package/dist/src/tasks.spec.d.ts +6 -0
- package/dist/src/tasks.spec.d.ts.map +1 -0
- package/dist/src/tasks.spec.js +105 -0
- package/dist/src/tasks.spec.js.map +1 -0
- package/dist/src/test-helpers.d.ts +5 -4
- package/dist/src/test-helpers.d.ts.map +1 -1
- package/dist/src/test-helpers.js +9 -21
- package/dist/src/test-helpers.js.map +1 -1
- package/dist/src/transfer/InMemoryTransferBackend.d.ts +75 -0
- package/dist/src/transfer/InMemoryTransferBackend.d.ts.map +1 -0
- package/dist/src/transfer/InMemoryTransferBackend.js +211 -0
- package/dist/src/transfer/InMemoryTransferBackend.js.map +1 -0
- package/dist/src/transfer/index.d.ts +9 -0
- package/dist/src/transfer/index.d.ts.map +1 -0
- package/dist/src/transfer/index.js +11 -0
- package/dist/src/transfer/index.js.map +1 -0
- package/dist/src/transfer/interfaces.d.ts +103 -0
- package/dist/src/transfer/interfaces.d.ts.map +1 -0
- package/dist/src/transfer/interfaces.js +6 -0
- package/dist/src/transfer/interfaces.js.map +1 -0
- package/dist/src/transfer/process.d.ts +55 -0
- package/dist/src/transfer/process.d.ts.map +1 -0
- package/dist/src/transfer/process.js +144 -0
- package/dist/src/transfer/process.js.map +1 -0
- package/dist/src/transfer/types.d.ts +106 -0
- package/dist/src/transfer/types.d.ts.map +1 -0
- package/dist/src/transfer/types.js +61 -0
- package/dist/src/transfer/types.js.map +1 -0
- package/dist/src/trees.d.ts +102 -63
- package/dist/src/trees.d.ts.map +1 -1
- package/dist/src/trees.js +319 -479
- package/dist/src/trees.js.map +1 -1
- package/dist/src/trees.spec.d.ts +6 -0
- package/dist/src/trees.spec.d.ts.map +1 -0
- package/dist/src/trees.spec.js +635 -0
- package/dist/src/trees.spec.js.map +1 -0
- package/dist/src/uuid.d.ts +26 -0
- package/dist/src/uuid.d.ts.map +1 -0
- package/dist/src/uuid.js +80 -0
- package/dist/src/uuid.js.map +1 -0
- package/dist/src/workspaceStatus.d.ts +6 -4
- package/dist/src/workspaceStatus.d.ts.map +1 -1
- package/dist/src/workspaceStatus.js +46 -60
- package/dist/src/workspaceStatus.js.map +1 -1
- package/dist/src/workspaces.d.ts +46 -47
- package/dist/src/workspaces.d.ts.map +1 -1
- package/dist/src/workspaces.js +281 -221
- package/dist/src/workspaces.js.map +1 -1
- package/dist/src/workspaces.spec.d.ts +6 -0
- package/dist/src/workspaces.spec.d.ts.map +1 -0
- package/dist/src/workspaces.spec.js +273 -0
- package/dist/src/workspaces.spec.js.map +1 -0
- package/package.json +15 -15
- package/dist/src/gc.d.ts +0 -54
- package/dist/src/gc.d.ts.map +0 -1
- package/dist/src/gc.js +0 -233
- package/dist/src/gc.js.map +0 -1
- package/dist/src/repository.d.ts.map +0 -1
- package/dist/src/repository.js.map +0 -1
- package/dist/src/workspaceLock.d.ts +0 -67
- package/dist/src/workspaceLock.d.ts.map +0 -1
- package/dist/src/workspaceLock.js +0 -217
- package/dist/src/workspaceLock.js.map +0 -1
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Elara AI Pty Ltd
|
|
3
|
+
* Licensed under BSL 1.1. See LICENSE for details.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Tests for dataflow orchestration using MockTaskRunner.
|
|
7
|
+
*
|
|
8
|
+
* These tests verify the dataflow execution logic (dependency ordering,
|
|
9
|
+
* concurrency limits, failure propagation, abort handling, caching)
|
|
10
|
+
* without spawning real processes.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
13
|
+
import assert from 'node:assert';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { variant, StringType, ArrayType, encodeBeast2For, East, IRType } from '@elaraai/east';
|
|
17
|
+
import { TaskObjectType, PackageObjectType, } from '@elaraai/e3-types';
|
|
18
|
+
import { dataflowExecute } from './dataflow.js';
|
|
19
|
+
import { datasetWrite } from './trees.js';
|
|
20
|
+
import { objectWrite } from './storage/local/LocalObjectStore.js';
|
|
21
|
+
import { workspaceDeploy } from './workspaces.js';
|
|
22
|
+
import { workspaceSetDataset } from './trees.js';
|
|
23
|
+
import { createTestRepo, removeTestRepo } from './test-helpers.js';
|
|
24
|
+
import { LocalStorage } from './storage/local/index.js';
|
|
25
|
+
import { MockTaskRunner } from './execution/MockTaskRunner.js';
|
|
26
|
+
import { inputsHash } from './executions.js';
|
|
27
|
+
describe('dataflow orchestration with MockTaskRunner', () => {
|
|
28
|
+
let testRepo;
|
|
29
|
+
let storage;
|
|
30
|
+
let mockRunner;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
testRepo = createTestRepo();
|
|
33
|
+
storage = new LocalStorage();
|
|
34
|
+
mockRunner = new MockTaskRunner();
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
removeTestRepo(testRepo);
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Helper to create a command IR object.
|
|
41
|
+
*/
|
|
42
|
+
async function createCommandIr(repoPath, parts) {
|
|
43
|
+
const commandFn = East.function([ArrayType(StringType), StringType], ArrayType(StringType), ($, inputs, output) => {
|
|
44
|
+
const result = [];
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
if (part === '{input}' || part === '{input0}') {
|
|
47
|
+
result.push(inputs.get(0n));
|
|
48
|
+
}
|
|
49
|
+
else if (part.match(/^\{input(\d+)\}$/)) {
|
|
50
|
+
const idx = BigInt(part.match(/^\{input(\d+)\}$/)[1]);
|
|
51
|
+
result.push(inputs.get(idx));
|
|
52
|
+
}
|
|
53
|
+
else if (part === '{output}') {
|
|
54
|
+
result.push(output);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
result.push(part);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
});
|
|
62
|
+
const ir = commandFn.toIR().ir;
|
|
63
|
+
const encoder = encodeBeast2For(IRType);
|
|
64
|
+
return objectWrite(repoPath, encoder(ir));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Helper to create a package with tasks.
|
|
68
|
+
* Returns a map of task names to task hashes.
|
|
69
|
+
*/
|
|
70
|
+
async function createPackageWithTasks(repoPath, tasks, structure) {
|
|
71
|
+
const taskEncoder = encodeBeast2For(TaskObjectType);
|
|
72
|
+
const tasksMap = new Map();
|
|
73
|
+
for (const t of tasks) {
|
|
74
|
+
const commandIrHash = await createCommandIr(repoPath, t.command);
|
|
75
|
+
const taskObj = {
|
|
76
|
+
commandIr: commandIrHash,
|
|
77
|
+
inputs: t.inputs,
|
|
78
|
+
output: t.output,
|
|
79
|
+
kind: variant('none', null), metadata: variant('none', null),
|
|
80
|
+
};
|
|
81
|
+
const taskHash = await objectWrite(repoPath, taskEncoder(taskObj));
|
|
82
|
+
tasksMap.set(t.name, taskHash);
|
|
83
|
+
}
|
|
84
|
+
// Create package object (no root tree — per-dataset refs are used instead)
|
|
85
|
+
const pkgEncoder = encodeBeast2For(PackageObjectType);
|
|
86
|
+
const pkgObj = {
|
|
87
|
+
data: {
|
|
88
|
+
structure,
|
|
89
|
+
refs: new Map(),
|
|
90
|
+
},
|
|
91
|
+
tasks: tasksMap,
|
|
92
|
+
};
|
|
93
|
+
const pkgHash = await objectWrite(repoPath, pkgEncoder(pkgObj));
|
|
94
|
+
const pkgDir = join(repoPath, 'packages', 'test');
|
|
95
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
96
|
+
writeFileSync(join(pkgDir, '1.0.0'), pkgHash + '\n');
|
|
97
|
+
return tasksMap;
|
|
98
|
+
}
|
|
99
|
+
describe('dependency ordering', () => {
|
|
100
|
+
it('executes tasks in topological order', async () => {
|
|
101
|
+
// Create package with A -> B -> C chain
|
|
102
|
+
const structure = {
|
|
103
|
+
type: 'struct',
|
|
104
|
+
value: new Map([
|
|
105
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
106
|
+
['middle1', { type: 'value', value: { type: StringType, writable: true } }],
|
|
107
|
+
['middle2', { type: 'value', value: { type: StringType, writable: true } }],
|
|
108
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
109
|
+
]),
|
|
110
|
+
};
|
|
111
|
+
const inputPath = [variant('field', 'input')];
|
|
112
|
+
const middle1Path = [variant('field', 'middle1')];
|
|
113
|
+
const middle2Path = [variant('field', 'middle2')];
|
|
114
|
+
const outputPath = [variant('field', 'output')];
|
|
115
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
116
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
117
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
118
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: middle1Path },
|
|
119
|
+
{ name: 'task-b', command: ['echo'], inputs: [middle1Path], output: middle2Path },
|
|
120
|
+
{ name: 'task-c', command: ['echo'], inputs: [middle2Path], output: outputPath },
|
|
121
|
+
], structure);
|
|
122
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
123
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
124
|
+
// Configure mock to return unique output hashes
|
|
125
|
+
for (const [name, hash] of taskHashes) {
|
|
126
|
+
mockRunner.setResult(hash, {
|
|
127
|
+
state: 'success',
|
|
128
|
+
cached: false,
|
|
129
|
+
outputHash: `output-${name}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const completedOrder = [];
|
|
133
|
+
await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
134
|
+
runner: mockRunner,
|
|
135
|
+
onTaskComplete: (r) => completedOrder.push(r.name),
|
|
136
|
+
});
|
|
137
|
+
// Verify execution order: A must complete before B, B before C
|
|
138
|
+
assert.strictEqual(completedOrder.indexOf('task-a') < completedOrder.indexOf('task-b'), true);
|
|
139
|
+
assert.strictEqual(completedOrder.indexOf('task-b') < completedOrder.indexOf('task-c'), true);
|
|
140
|
+
});
|
|
141
|
+
it('executes independent tasks in parallel', async () => {
|
|
142
|
+
// Create package with diamond: A -> B, A -> C, B+C -> D
|
|
143
|
+
const structure = {
|
|
144
|
+
type: 'struct',
|
|
145
|
+
value: new Map([
|
|
146
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
147
|
+
['out_a', { type: 'value', value: { type: StringType, writable: true } }],
|
|
148
|
+
['out_b', { type: 'value', value: { type: StringType, writable: true } }],
|
|
149
|
+
['out_c', { type: 'value', value: { type: StringType, writable: true } }],
|
|
150
|
+
]),
|
|
151
|
+
};
|
|
152
|
+
const inputPath = [variant('field', 'input')];
|
|
153
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
154
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
155
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
156
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_a')] },
|
|
157
|
+
{ name: 'task-b', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_b')] },
|
|
158
|
+
{ name: 'task-c', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_c')] },
|
|
159
|
+
], structure);
|
|
160
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
161
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
162
|
+
// Configure mock results
|
|
163
|
+
for (const [name, hash] of taskHashes) {
|
|
164
|
+
mockRunner.setResult(hash, {
|
|
165
|
+
state: 'success',
|
|
166
|
+
cached: false,
|
|
167
|
+
outputHash: `output-${name}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
171
|
+
runner: mockRunner,
|
|
172
|
+
concurrency: 4,
|
|
173
|
+
});
|
|
174
|
+
assert.strictEqual(result.success, true);
|
|
175
|
+
assert.strictEqual(result.executed, 3);
|
|
176
|
+
// Verify all tasks were called
|
|
177
|
+
const calls = mockRunner.getCalls();
|
|
178
|
+
assert.strictEqual(calls.length, 3);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('concurrency limit', () => {
|
|
182
|
+
it('respects concurrency limit with mock runner', async () => {
|
|
183
|
+
// Create package with 4 independent tasks
|
|
184
|
+
const structure = {
|
|
185
|
+
type: 'struct',
|
|
186
|
+
value: new Map([
|
|
187
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
188
|
+
['out1', { type: 'value', value: { type: StringType, writable: true } }],
|
|
189
|
+
['out2', { type: 'value', value: { type: StringType, writable: true } }],
|
|
190
|
+
['out3', { type: 'value', value: { type: StringType, writable: true } }],
|
|
191
|
+
['out4', { type: 'value', value: { type: StringType, writable: true } }],
|
|
192
|
+
]),
|
|
193
|
+
};
|
|
194
|
+
const inputPath = [variant('field', 'input')];
|
|
195
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
196
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
197
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
198
|
+
{ name: 'task-1', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out1')] },
|
|
199
|
+
{ name: 'task-2', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out2')] },
|
|
200
|
+
{ name: 'task-3', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out3')] },
|
|
201
|
+
{ name: 'task-4', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out4')] },
|
|
202
|
+
], structure);
|
|
203
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
204
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
205
|
+
// Configure mock to add delay and track concurrency
|
|
206
|
+
let currentConcurrent = 0;
|
|
207
|
+
let maxConcurrent = 0;
|
|
208
|
+
for (const [name, hash] of taskHashes) {
|
|
209
|
+
mockRunner.setResult(hash, () => {
|
|
210
|
+
currentConcurrent++;
|
|
211
|
+
maxConcurrent = Math.max(maxConcurrent, currentConcurrent);
|
|
212
|
+
// Simulate async work then decrement
|
|
213
|
+
return {
|
|
214
|
+
state: 'success',
|
|
215
|
+
cached: false,
|
|
216
|
+
outputHash: `output-${name}`,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// Track via callbacks since mock execute is sync
|
|
221
|
+
let startCount = 0;
|
|
222
|
+
await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
223
|
+
runner: mockRunner,
|
|
224
|
+
concurrency: 2,
|
|
225
|
+
onTaskStart: () => {
|
|
226
|
+
startCount++;
|
|
227
|
+
},
|
|
228
|
+
onTaskComplete: () => {
|
|
229
|
+
currentConcurrent--;
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
assert.strictEqual(startCount, 4);
|
|
233
|
+
// Note: With synchronous mock, concurrency tracking through callbacks works differently
|
|
234
|
+
// The key test is that all tasks were executed
|
|
235
|
+
const calls = mockRunner.getCalls();
|
|
236
|
+
assert.strictEqual(calls.length, 4);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
describe('cache behavior', () => {
|
|
240
|
+
it('counts tasks as cached when runner returns cached: true', async () => {
|
|
241
|
+
const structure = {
|
|
242
|
+
type: 'struct',
|
|
243
|
+
value: new Map([
|
|
244
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
245
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
246
|
+
]),
|
|
247
|
+
};
|
|
248
|
+
const inputPath = [variant('field', 'input')];
|
|
249
|
+
const outputPath = [variant('field', 'output')];
|
|
250
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
251
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
252
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
253
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
254
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
255
|
+
// First run: not cached
|
|
256
|
+
for (const [, hash] of taskHashes) {
|
|
257
|
+
mockRunner.setResult(hash, {
|
|
258
|
+
state: 'success',
|
|
259
|
+
cached: false,
|
|
260
|
+
outputHash: 'output-hash',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const result1 = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
264
|
+
runner: mockRunner,
|
|
265
|
+
});
|
|
266
|
+
assert.strictEqual(result1.executed, 1);
|
|
267
|
+
assert.strictEqual(result1.cached, 0);
|
|
268
|
+
// Second run: runner returns cached: true
|
|
269
|
+
mockRunner.clearCalls();
|
|
270
|
+
for (const [, hash] of taskHashes) {
|
|
271
|
+
mockRunner.setResult(hash, {
|
|
272
|
+
state: 'success',
|
|
273
|
+
cached: true,
|
|
274
|
+
outputHash: 'output-hash',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const result2 = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
278
|
+
runner: mockRunner,
|
|
279
|
+
});
|
|
280
|
+
// Note: The dataflow has its own cache check before calling the runner.
|
|
281
|
+
// If the workspace output already matches the cached output, runner isn't called.
|
|
282
|
+
// In this test, we're verifying that if runner IS called and returns cached: true,
|
|
283
|
+
// it's counted correctly.
|
|
284
|
+
assert.strictEqual(result2.success, true);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
describe('failure propagation', () => {
|
|
288
|
+
it('skips downstream tasks when upstream fails', async () => {
|
|
289
|
+
// Create A -> B -> C, where A fails
|
|
290
|
+
const structure = {
|
|
291
|
+
type: 'struct',
|
|
292
|
+
value: new Map([
|
|
293
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
294
|
+
['middle1', { type: 'value', value: { type: StringType, writable: true } }],
|
|
295
|
+
['middle2', { type: 'value', value: { type: StringType, writable: true } }],
|
|
296
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
297
|
+
]),
|
|
298
|
+
};
|
|
299
|
+
const inputPath = [variant('field', 'input')];
|
|
300
|
+
const middle1Path = [variant('field', 'middle1')];
|
|
301
|
+
const middle2Path = [variant('field', 'middle2')];
|
|
302
|
+
const outputPath = [variant('field', 'output')];
|
|
303
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
304
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
305
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
306
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: middle1Path },
|
|
307
|
+
{ name: 'task-b', command: ['echo'], inputs: [middle1Path], output: middle2Path },
|
|
308
|
+
{ name: 'task-c', command: ['echo'], inputs: [middle2Path], output: outputPath },
|
|
309
|
+
], structure);
|
|
310
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
311
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
312
|
+
// task-a fails, others should succeed if called
|
|
313
|
+
mockRunner.setResult(taskHashes.get('task-a'), {
|
|
314
|
+
state: 'failed',
|
|
315
|
+
cached: false,
|
|
316
|
+
exitCode: 1,
|
|
317
|
+
});
|
|
318
|
+
mockRunner.setResult(taskHashes.get('task-b'), {
|
|
319
|
+
state: 'success',
|
|
320
|
+
cached: false,
|
|
321
|
+
outputHash: 'output-b',
|
|
322
|
+
});
|
|
323
|
+
mockRunner.setResult(taskHashes.get('task-c'), {
|
|
324
|
+
state: 'success',
|
|
325
|
+
cached: false,
|
|
326
|
+
outputHash: 'output-c',
|
|
327
|
+
});
|
|
328
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
329
|
+
runner: mockRunner,
|
|
330
|
+
});
|
|
331
|
+
assert.strictEqual(result.success, false);
|
|
332
|
+
assert.strictEqual(result.failed, 1);
|
|
333
|
+
assert.strictEqual(result.skipped, 2); // B and C should be skipped
|
|
334
|
+
const taskA = result.tasks.find(t => t.name === 'task-a');
|
|
335
|
+
const taskB = result.tasks.find(t => t.name === 'task-b');
|
|
336
|
+
const taskC = result.tasks.find(t => t.name === 'task-c');
|
|
337
|
+
assert.strictEqual(taskA?.state, 'failed');
|
|
338
|
+
assert.strictEqual(taskB?.state, 'skipped');
|
|
339
|
+
assert.strictEqual(taskC?.state, 'skipped');
|
|
340
|
+
// Only task-a should have been called
|
|
341
|
+
const calls = mockRunner.getCalls();
|
|
342
|
+
assert.strictEqual(calls.length, 1);
|
|
343
|
+
assert.strictEqual(calls[0].taskHash, taskHashes.get('task-a'));
|
|
344
|
+
});
|
|
345
|
+
it('handles error state from runner', async () => {
|
|
346
|
+
const structure = {
|
|
347
|
+
type: 'struct',
|
|
348
|
+
value: new Map([
|
|
349
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
350
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
351
|
+
]),
|
|
352
|
+
};
|
|
353
|
+
const inputPath = [variant('field', 'input')];
|
|
354
|
+
const outputPath = [variant('field', 'output')];
|
|
355
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
356
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
357
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
358
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
359
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
360
|
+
// Runner returns error state
|
|
361
|
+
mockRunner.setResult(taskHashes.get('task'), {
|
|
362
|
+
state: 'error',
|
|
363
|
+
cached: false,
|
|
364
|
+
error: 'Internal error',
|
|
365
|
+
});
|
|
366
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
367
|
+
runner: mockRunner,
|
|
368
|
+
});
|
|
369
|
+
assert.strictEqual(result.success, false);
|
|
370
|
+
assert.strictEqual(result.failed, 1);
|
|
371
|
+
const task = result.tasks.find(t => t.name === 'task');
|
|
372
|
+
assert.strictEqual(task?.state, 'error');
|
|
373
|
+
assert.strictEqual(task?.error, 'Internal error');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
describe('abort handling', () => {
|
|
377
|
+
it('does not start tasks when signal is pre-aborted', async () => {
|
|
378
|
+
// Create an independent task
|
|
379
|
+
const structure = {
|
|
380
|
+
type: 'struct',
|
|
381
|
+
value: new Map([
|
|
382
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
383
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
384
|
+
]),
|
|
385
|
+
};
|
|
386
|
+
const inputPath = [variant('field', 'input')];
|
|
387
|
+
const outputPath = [variant('field', 'output')];
|
|
388
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
389
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
390
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
391
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
392
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
393
|
+
mockRunner.setResult(taskHashes.get('task'), {
|
|
394
|
+
state: 'success',
|
|
395
|
+
cached: false,
|
|
396
|
+
outputHash: 'output-hash',
|
|
397
|
+
});
|
|
398
|
+
// Pre-abort the signal before execution starts
|
|
399
|
+
const controller = new AbortController();
|
|
400
|
+
controller.abort();
|
|
401
|
+
const { DataflowAbortedError } = await import('./errors.js');
|
|
402
|
+
await assert.rejects(dataflowExecute(storage, testRepo, 'test-ws', {
|
|
403
|
+
runner: mockRunner,
|
|
404
|
+
signal: controller.signal,
|
|
405
|
+
}), (err) => {
|
|
406
|
+
assert.ok(err instanceof DataflowAbortedError);
|
|
407
|
+
return true;
|
|
408
|
+
});
|
|
409
|
+
// No tasks should have been executed since signal was pre-aborted
|
|
410
|
+
const calls = mockRunner.getCalls();
|
|
411
|
+
assert.strictEqual(calls.length, 0, 'No tasks should execute when signal is pre-aborted');
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
describe('input hash passing', () => {
|
|
415
|
+
it('passes correct input hashes to runner', async () => {
|
|
416
|
+
const structure = {
|
|
417
|
+
type: 'struct',
|
|
418
|
+
value: new Map([
|
|
419
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
420
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
421
|
+
]),
|
|
422
|
+
};
|
|
423
|
+
const inputPath = [variant('field', 'input')];
|
|
424
|
+
const outputPath = [variant('field', 'output')];
|
|
425
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
426
|
+
const inputHash = await objectWrite(testRepo, inputEncoder('specific-value'));
|
|
427
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
428
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
429
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'specific-value', StringType);
|
|
430
|
+
// Set up mock to capture input hashes
|
|
431
|
+
let capturedInputHashes = [];
|
|
432
|
+
mockRunner.setResult(taskHashes.get('task'), (inputHashes) => {
|
|
433
|
+
capturedInputHashes = [...inputHashes];
|
|
434
|
+
return {
|
|
435
|
+
state: 'success',
|
|
436
|
+
cached: false,
|
|
437
|
+
outputHash: 'output-hash',
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
441
|
+
runner: mockRunner,
|
|
442
|
+
});
|
|
443
|
+
// Verify the input hash was passed correctly
|
|
444
|
+
assert.strictEqual(capturedInputHashes.length, 1);
|
|
445
|
+
assert.strictEqual(capturedInputHashes[0], inputHash);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
describe('callback invocation', () => {
|
|
449
|
+
it('calls onTaskStart and onTaskComplete callbacks', async () => {
|
|
450
|
+
const structure = {
|
|
451
|
+
type: 'struct',
|
|
452
|
+
value: new Map([
|
|
453
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
454
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
455
|
+
]),
|
|
456
|
+
};
|
|
457
|
+
const inputPath = [variant('field', 'input')];
|
|
458
|
+
const outputPath = [variant('field', 'output')];
|
|
459
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
460
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
461
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'my-task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
462
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
463
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
464
|
+
mockRunner.setResult(taskHashes.get('my-task'), {
|
|
465
|
+
state: 'success',
|
|
466
|
+
cached: false,
|
|
467
|
+
outputHash: 'output-hash',
|
|
468
|
+
});
|
|
469
|
+
const startedTasks = [];
|
|
470
|
+
const completedTasks = [];
|
|
471
|
+
await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
472
|
+
runner: mockRunner,
|
|
473
|
+
onTaskStart: (name) => startedTasks.push(name),
|
|
474
|
+
onTaskComplete: (result) => completedTasks.push(result.name),
|
|
475
|
+
});
|
|
476
|
+
assert.deepStrictEqual(startedTasks, ['my-task']);
|
|
477
|
+
assert.deepStrictEqual(completedTasks, ['my-task']);
|
|
478
|
+
});
|
|
479
|
+
it('passes stdout/stderr callbacks to runner', async () => {
|
|
480
|
+
const structure = {
|
|
481
|
+
type: 'struct',
|
|
482
|
+
value: new Map([
|
|
483
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
484
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
485
|
+
]),
|
|
486
|
+
};
|
|
487
|
+
const inputPath = [variant('field', 'input')];
|
|
488
|
+
const outputPath = [variant('field', 'output')];
|
|
489
|
+
const inputEncoder = encodeBeast2For(StringType);
|
|
490
|
+
const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
|
|
491
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
492
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
493
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
494
|
+
// Capture the options passed to runner
|
|
495
|
+
let _capturedOptions;
|
|
496
|
+
mockRunner.setResult(taskHashes.get('task'), (_inputHashes) => {
|
|
497
|
+
_capturedOptions = mockRunner.getCalls()[0]?.options;
|
|
498
|
+
return {
|
|
499
|
+
state: 'success',
|
|
500
|
+
cached: false,
|
|
501
|
+
outputHash: 'output-hash',
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
const stdoutCalls = [];
|
|
505
|
+
const stderrCalls = [];
|
|
506
|
+
await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
507
|
+
runner: mockRunner,
|
|
508
|
+
onStdout: (task, data) => stdoutCalls.push({ task, data }),
|
|
509
|
+
onStderr: (task, data) => stderrCalls.push({ task, data }),
|
|
510
|
+
});
|
|
511
|
+
// Verify callbacks were passed to runner's options
|
|
512
|
+
const call = mockRunner.getCalls()[0];
|
|
513
|
+
assert.ok(call.options?.onStdout, 'onStdout should be passed to runner');
|
|
514
|
+
assert.ok(call.options?.onStderr, 'onStderr should be passed to runner');
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
describe('reactive dataflow', () => {
|
|
518
|
+
it('reaches fixpoint without re-execution when inputs unchanged', async () => {
|
|
519
|
+
// Normal execution, no input changes → same behavior as before
|
|
520
|
+
const structure = {
|
|
521
|
+
type: 'struct',
|
|
522
|
+
value: new Map([
|
|
523
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
524
|
+
['middle', { type: 'value', value: { type: StringType, writable: true } }],
|
|
525
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
526
|
+
]),
|
|
527
|
+
};
|
|
528
|
+
const inputPath = [variant('field', 'input')];
|
|
529
|
+
const middlePath = [variant('field', 'middle')];
|
|
530
|
+
const outputPath = [variant('field', 'output')];
|
|
531
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
532
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: middlePath },
|
|
533
|
+
{ name: 'task-b', command: ['echo'], inputs: [middlePath], output: outputPath },
|
|
534
|
+
], structure);
|
|
535
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
536
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
537
|
+
for (const [name, hash] of taskHashes) {
|
|
538
|
+
mockRunner.setResult(hash, {
|
|
539
|
+
state: 'success',
|
|
540
|
+
cached: false,
|
|
541
|
+
outputHash: `output-${name}`,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
545
|
+
runner: mockRunner,
|
|
546
|
+
});
|
|
547
|
+
assert.strictEqual(result.success, true);
|
|
548
|
+
assert.strictEqual(result.executed, 2);
|
|
549
|
+
assert.strictEqual(result.reexecuted, 0);
|
|
550
|
+
});
|
|
551
|
+
it('re-executes downstream tasks when input changes during execution', async () => {
|
|
552
|
+
// Setup: input → taskA → output
|
|
553
|
+
// MockTaskRunner for taskA: during execution, write new value to input ref
|
|
554
|
+
// After taskA completes, reactive loop detects change, invalidates taskA
|
|
555
|
+
// taskA re-runs with new input
|
|
556
|
+
const structure = {
|
|
557
|
+
type: 'struct',
|
|
558
|
+
value: new Map([
|
|
559
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
560
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
561
|
+
]),
|
|
562
|
+
};
|
|
563
|
+
const inputPath = [variant('field', 'input')];
|
|
564
|
+
const outputPath = [variant('field', 'output')];
|
|
565
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
566
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
|
|
567
|
+
], structure);
|
|
568
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
569
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial-value', StringType);
|
|
570
|
+
let callCount = 0;
|
|
571
|
+
const taskAHash = taskHashes.get('task-a');
|
|
572
|
+
mockRunner.setResult(taskAHash, async (_inputHashes) => {
|
|
573
|
+
callCount++;
|
|
574
|
+
if (callCount === 1) {
|
|
575
|
+
// On first execution, simulate a concurrent input change
|
|
576
|
+
const newHash = await datasetWrite(storage, testRepo, 'changed-value', StringType);
|
|
577
|
+
const ref = variant('value', { hash: newHash, versions: new Map() });
|
|
578
|
+
await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
state: 'success',
|
|
582
|
+
cached: false,
|
|
583
|
+
outputHash: `output-v${callCount}`,
|
|
584
|
+
};
|
|
585
|
+
});
|
|
586
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
587
|
+
runner: mockRunner,
|
|
588
|
+
});
|
|
589
|
+
assert.strictEqual(result.success, true);
|
|
590
|
+
// Should have executed task-a twice: once initially, once after input change
|
|
591
|
+
assert.strictEqual(callCount, 2);
|
|
592
|
+
assert.strictEqual(result.reexecuted, 1);
|
|
593
|
+
// executed counts final unique tasks, not total calls
|
|
594
|
+
assert.strictEqual(result.executed, 1);
|
|
595
|
+
});
|
|
596
|
+
it('re-executes chain when input changes during execution', async () => {
|
|
597
|
+
// Setup: input → taskA → middle → taskB → output
|
|
598
|
+
// During taskA execution, input changes
|
|
599
|
+
// taskA should re-run, then taskB should run with new output
|
|
600
|
+
const structure = {
|
|
601
|
+
type: 'struct',
|
|
602
|
+
value: new Map([
|
|
603
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
604
|
+
['middle', { type: 'value', value: { type: StringType, writable: true } }],
|
|
605
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
606
|
+
]),
|
|
607
|
+
};
|
|
608
|
+
const inputPath = [variant('field', 'input')];
|
|
609
|
+
const middlePath = [variant('field', 'middle')];
|
|
610
|
+
const outputPath = [variant('field', 'output')];
|
|
611
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
612
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: middlePath },
|
|
613
|
+
{ name: 'task-b', command: ['echo'], inputs: [middlePath], output: outputPath },
|
|
614
|
+
], structure);
|
|
615
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
616
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial-value', StringType);
|
|
617
|
+
let taskACallCount = 0;
|
|
618
|
+
let taskBCallCount = 0;
|
|
619
|
+
const taskAHash = taskHashes.get('task-a');
|
|
620
|
+
const taskBHash = taskHashes.get('task-b');
|
|
621
|
+
mockRunner.setResult(taskAHash, async (_inputHashes) => {
|
|
622
|
+
taskACallCount++;
|
|
623
|
+
if (taskACallCount === 1) {
|
|
624
|
+
// On first execution, simulate a concurrent input change
|
|
625
|
+
const newHash = await datasetWrite(storage, testRepo, 'changed-value', StringType);
|
|
626
|
+
const ref = variant('value', { hash: newHash, versions: new Map() });
|
|
627
|
+
await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
state: 'success',
|
|
631
|
+
cached: false,
|
|
632
|
+
outputHash: `middle-v${taskACallCount}`,
|
|
633
|
+
};
|
|
634
|
+
});
|
|
635
|
+
mockRunner.setResult(taskBHash, (_inputHashes) => {
|
|
636
|
+
taskBCallCount++;
|
|
637
|
+
return {
|
|
638
|
+
state: 'success',
|
|
639
|
+
cached: false,
|
|
640
|
+
outputHash: `output-v${taskBCallCount}`,
|
|
641
|
+
};
|
|
642
|
+
});
|
|
643
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
644
|
+
runner: mockRunner,
|
|
645
|
+
});
|
|
646
|
+
assert.strictEqual(result.success, true);
|
|
647
|
+
// taskA should run twice (initial + re-execution after input change)
|
|
648
|
+
assert.strictEqual(taskACallCount, 2);
|
|
649
|
+
// taskB should run once (blocked until taskA re-executes, then runs with fresh data)
|
|
650
|
+
assert.strictEqual(taskBCallCount, 1);
|
|
651
|
+
// One re-execution (taskA)
|
|
652
|
+
assert.strictEqual(result.reexecuted, 1);
|
|
653
|
+
});
|
|
654
|
+
it('tracks reexecuted count correctly', async () => {
|
|
655
|
+
const structure = {
|
|
656
|
+
type: 'struct',
|
|
657
|
+
value: new Map([
|
|
658
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
659
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
660
|
+
]),
|
|
661
|
+
};
|
|
662
|
+
const inputPath = [variant('field', 'input')];
|
|
663
|
+
const outputPath = [variant('field', 'output')];
|
|
664
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
665
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
|
|
666
|
+
], structure);
|
|
667
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
668
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'v1', StringType);
|
|
669
|
+
let callCount = 0;
|
|
670
|
+
const taskAHash = taskHashes.get('task-a');
|
|
671
|
+
mockRunner.setResult(taskAHash, async (_inputHashes) => {
|
|
672
|
+
callCount++;
|
|
673
|
+
if (callCount <= 2) {
|
|
674
|
+
// First two calls: write a new input value
|
|
675
|
+
const newHash = await datasetWrite(storage, testRepo, `v${callCount + 1}`, StringType);
|
|
676
|
+
const ref = variant('value', { hash: newHash, versions: new Map() });
|
|
677
|
+
await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
state: 'success',
|
|
681
|
+
cached: false,
|
|
682
|
+
outputHash: `output-v${callCount}`,
|
|
683
|
+
};
|
|
684
|
+
});
|
|
685
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
686
|
+
runner: mockRunner,
|
|
687
|
+
});
|
|
688
|
+
assert.strictEqual(result.success, true);
|
|
689
|
+
// Should execute 3 times total: initial + 2 re-executions
|
|
690
|
+
assert.strictEqual(callCount, 3);
|
|
691
|
+
assert.strictEqual(result.reexecuted, 2);
|
|
692
|
+
});
|
|
693
|
+
it('calls onInputChanged callback', async () => {
|
|
694
|
+
const structure = {
|
|
695
|
+
type: 'struct',
|
|
696
|
+
value: new Map([
|
|
697
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
698
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
699
|
+
]),
|
|
700
|
+
};
|
|
701
|
+
const inputPath = [variant('field', 'input')];
|
|
702
|
+
const outputPath = [variant('field', 'output')];
|
|
703
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
704
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
|
|
705
|
+
], structure);
|
|
706
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
707
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial', StringType);
|
|
708
|
+
let callCount = 0;
|
|
709
|
+
const taskAHash = taskHashes.get('task-a');
|
|
710
|
+
mockRunner.setResult(taskAHash, async (_inputHashes) => {
|
|
711
|
+
callCount++;
|
|
712
|
+
if (callCount === 1) {
|
|
713
|
+
const newHash = await datasetWrite(storage, testRepo, 'changed', StringType);
|
|
714
|
+
const ref = variant('value', { hash: newHash, versions: new Map() });
|
|
715
|
+
await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
state: 'success',
|
|
719
|
+
cached: false,
|
|
720
|
+
outputHash: `output-v${callCount}`,
|
|
721
|
+
};
|
|
722
|
+
});
|
|
723
|
+
const inputChanges = [];
|
|
724
|
+
await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
725
|
+
runner: mockRunner,
|
|
726
|
+
onInputChanged: (path, previousHash, newHash) => {
|
|
727
|
+
inputChanges.push({ path, previousHash, newHash });
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
assert.strictEqual(inputChanges.length, 1);
|
|
731
|
+
assert.strictEqual(inputChanges[0].path, '.input');
|
|
732
|
+
assert.ok(inputChanges[0].previousHash.length > 0);
|
|
733
|
+
assert.ok(inputChanges[0].newHash.length > 0);
|
|
734
|
+
assert.notStrictEqual(inputChanges[0].previousHash, inputChanges[0].newHash);
|
|
735
|
+
});
|
|
736
|
+
it('calls onTaskInvalidated callback', async () => {
|
|
737
|
+
const structure = {
|
|
738
|
+
type: 'struct',
|
|
739
|
+
value: new Map([
|
|
740
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
741
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
742
|
+
]),
|
|
743
|
+
};
|
|
744
|
+
const inputPath = [variant('field', 'input')];
|
|
745
|
+
const outputPath = [variant('field', 'output')];
|
|
746
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
747
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
|
|
748
|
+
], structure);
|
|
749
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
750
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial', StringType);
|
|
751
|
+
let callCount = 0;
|
|
752
|
+
const taskAHash = taskHashes.get('task-a');
|
|
753
|
+
mockRunner.setResult(taskAHash, async (_inputHashes) => {
|
|
754
|
+
callCount++;
|
|
755
|
+
if (callCount === 1) {
|
|
756
|
+
const newHash = await datasetWrite(storage, testRepo, 'changed', StringType);
|
|
757
|
+
const ref = variant('value', { hash: newHash, versions: new Map() });
|
|
758
|
+
await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
state: 'success',
|
|
762
|
+
cached: false,
|
|
763
|
+
outputHash: `output-v${callCount}`,
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
const invalidated = [];
|
|
767
|
+
await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
768
|
+
runner: mockRunner,
|
|
769
|
+
onTaskInvalidated: (name, reason) => {
|
|
770
|
+
invalidated.push({ name, reason });
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
assert.strictEqual(invalidated.length, 1);
|
|
774
|
+
assert.strictEqual(invalidated[0].name, 'task-a');
|
|
775
|
+
assert.ok(invalidated[0].reason.includes('.input'), `Reason should mention input path, got: ${invalidated[0].reason}`);
|
|
776
|
+
});
|
|
777
|
+
it('handles no-op change (same hash)', async () => {
|
|
778
|
+
// Input "changes" but to same hash value → no invalidation
|
|
779
|
+
const structure = {
|
|
780
|
+
type: 'struct',
|
|
781
|
+
value: new Map([
|
|
782
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
783
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
784
|
+
]),
|
|
785
|
+
};
|
|
786
|
+
const inputPath = [variant('field', 'input')];
|
|
787
|
+
const outputPath = [variant('field', 'output')];
|
|
788
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
789
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
|
|
790
|
+
], structure);
|
|
791
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
792
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'same-value', StringType);
|
|
793
|
+
let callCount = 0;
|
|
794
|
+
const taskAHash = taskHashes.get('task-a');
|
|
795
|
+
mockRunner.setResult(taskAHash, async (_inputHashes) => {
|
|
796
|
+
callCount++;
|
|
797
|
+
if (callCount === 1) {
|
|
798
|
+
// Write the same value — hash should not change
|
|
799
|
+
const sameHash = await datasetWrite(storage, testRepo, 'same-value', StringType);
|
|
800
|
+
const ref = variant('value', { hash: sameHash, versions: new Map() });
|
|
801
|
+
await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
state: 'success',
|
|
805
|
+
cached: false,
|
|
806
|
+
outputHash: `output-v${callCount}`,
|
|
807
|
+
};
|
|
808
|
+
});
|
|
809
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
810
|
+
runner: mockRunner,
|
|
811
|
+
});
|
|
812
|
+
assert.strictEqual(result.success, true);
|
|
813
|
+
// Should NOT re-execute since the hash is the same
|
|
814
|
+
assert.strictEqual(callCount, 1);
|
|
815
|
+
assert.strictEqual(result.reexecuted, 0);
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
describe('DataflowRun recording', () => {
|
|
819
|
+
it('records correct outputVersions with task output hashes', async () => {
|
|
820
|
+
const structure = {
|
|
821
|
+
type: 'struct',
|
|
822
|
+
value: new Map([
|
|
823
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
824
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
825
|
+
]),
|
|
826
|
+
};
|
|
827
|
+
const inputPath = [variant('field', 'input')];
|
|
828
|
+
const outputPath = [variant('field', 'output')];
|
|
829
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
830
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
831
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
832
|
+
mockRunner.setResult(taskHashes.get('task-a'), {
|
|
833
|
+
state: 'success',
|
|
834
|
+
cached: false,
|
|
835
|
+
outputHash: 'task-a-output-hash',
|
|
836
|
+
});
|
|
837
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
838
|
+
runner: mockRunner,
|
|
839
|
+
});
|
|
840
|
+
assert.strictEqual(result.success, true);
|
|
841
|
+
const run = await storage.refs.dataflowRunGetLatest(testRepo, 'test-ws');
|
|
842
|
+
assert.ok(run, 'DataflowRun should exist');
|
|
843
|
+
assert.strictEqual(run.outputVersions.type, 'some');
|
|
844
|
+
const outputVersions = run.outputVersions.value;
|
|
845
|
+
assert.strictEqual(outputVersions.get('.output'), 'task-a-output-hash');
|
|
846
|
+
assert.strictEqual(outputVersions.has('.input'), false, 'Input should not appear in outputVersions');
|
|
847
|
+
});
|
|
848
|
+
it('records outputVersions for all completed tasks in a chain', async () => {
|
|
849
|
+
const structure = {
|
|
850
|
+
type: 'struct',
|
|
851
|
+
value: new Map([
|
|
852
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
853
|
+
['middle', { type: 'value', value: { type: StringType, writable: true } }],
|
|
854
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
855
|
+
]),
|
|
856
|
+
};
|
|
857
|
+
const inputPath = [variant('field', 'input')];
|
|
858
|
+
const middlePath = [variant('field', 'middle')];
|
|
859
|
+
const outputPath = [variant('field', 'output')];
|
|
860
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
861
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: middlePath },
|
|
862
|
+
{ name: 'task-b', command: ['echo'], inputs: [middlePath], output: outputPath },
|
|
863
|
+
], structure);
|
|
864
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
865
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
866
|
+
mockRunner.setResult(taskHashes.get('task-a'), {
|
|
867
|
+
state: 'success',
|
|
868
|
+
cached: false,
|
|
869
|
+
outputHash: 'middle-hash',
|
|
870
|
+
});
|
|
871
|
+
mockRunner.setResult(taskHashes.get('task-b'), {
|
|
872
|
+
state: 'success',
|
|
873
|
+
cached: false,
|
|
874
|
+
outputHash: 'output-hash',
|
|
875
|
+
});
|
|
876
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
877
|
+
runner: mockRunner,
|
|
878
|
+
});
|
|
879
|
+
assert.strictEqual(result.success, true);
|
|
880
|
+
const run = await storage.refs.dataflowRunGetLatest(testRepo, 'test-ws');
|
|
881
|
+
assert.ok(run, 'DataflowRun should exist');
|
|
882
|
+
assert.strictEqual(run.outputVersions.type, 'some');
|
|
883
|
+
const outputVersions = run.outputVersions.value;
|
|
884
|
+
assert.strictEqual(outputVersions.get('.middle'), 'middle-hash');
|
|
885
|
+
assert.strictEqual(outputVersions.get('.output'), 'output-hash');
|
|
886
|
+
});
|
|
887
|
+
it('records partial outputVersions when execution is cancelled', async () => {
|
|
888
|
+
const structure = {
|
|
889
|
+
type: 'struct',
|
|
890
|
+
value: new Map([
|
|
891
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
892
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
893
|
+
]),
|
|
894
|
+
};
|
|
895
|
+
const inputPath = [variant('field', 'input')];
|
|
896
|
+
const outputPath = [variant('field', 'output')];
|
|
897
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
898
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
899
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
900
|
+
const controller = new AbortController();
|
|
901
|
+
// Task aborts during execution
|
|
902
|
+
mockRunner.setResult(taskHashes.get('task-a'), async () => {
|
|
903
|
+
controller.abort();
|
|
904
|
+
// Small delay to let abort propagate
|
|
905
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
906
|
+
return {
|
|
907
|
+
state: 'success',
|
|
908
|
+
cached: false,
|
|
909
|
+
outputHash: 'task-a-output-hash',
|
|
910
|
+
};
|
|
911
|
+
});
|
|
912
|
+
const { DataflowAbortedError } = await import('./errors.js');
|
|
913
|
+
await assert.rejects(dataflowExecute(storage, testRepo, 'test-ws', {
|
|
914
|
+
runner: mockRunner,
|
|
915
|
+
signal: controller.signal,
|
|
916
|
+
}), (err) => {
|
|
917
|
+
assert.ok(err instanceof DataflowAbortedError);
|
|
918
|
+
return true;
|
|
919
|
+
});
|
|
920
|
+
const run = await storage.refs.dataflowRunGetLatest(testRepo, 'test-ws');
|
|
921
|
+
assert.ok(run, 'DataflowRun should exist after cancellation');
|
|
922
|
+
assert.strictEqual(run.status.type, 'cancelled');
|
|
923
|
+
assert.strictEqual(run.outputVersions.type, 'some');
|
|
924
|
+
// Input should not appear in outputVersions even on cancellation
|
|
925
|
+
assert.strictEqual(run.outputVersions.value.has('.input'), false);
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
describe('abort cleanup', () => {
|
|
929
|
+
it('removes abort listener after normal completion', async () => {
|
|
930
|
+
const structure = {
|
|
931
|
+
type: 'struct',
|
|
932
|
+
value: new Map([
|
|
933
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
934
|
+
['output', { type: 'value', value: { type: StringType, writable: true } }],
|
|
935
|
+
]),
|
|
936
|
+
};
|
|
937
|
+
const inputPath = [variant('field', 'input')];
|
|
938
|
+
const outputPath = [variant('field', 'output')];
|
|
939
|
+
const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
|
|
940
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
941
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
942
|
+
mockRunner.setResult(taskHashes.get('task'), {
|
|
943
|
+
state: 'success',
|
|
944
|
+
cached: false,
|
|
945
|
+
outputHash: 'output-hash',
|
|
946
|
+
});
|
|
947
|
+
const controller = new AbortController();
|
|
948
|
+
const result = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
949
|
+
runner: mockRunner,
|
|
950
|
+
signal: controller.signal,
|
|
951
|
+
});
|
|
952
|
+
assert.strictEqual(result.success, true);
|
|
953
|
+
// After execution completes, aborting should not throw.
|
|
954
|
+
// If the abort listener were still attached, it could attempt to write
|
|
955
|
+
// to a cleaned-up state store and throw.
|
|
956
|
+
assert.doesNotThrow(() => controller.abort());
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
describe('cache-hit mutex', () => {
|
|
960
|
+
it('correctly handles cache hit during concurrent execution', async () => {
|
|
961
|
+
const structure = {
|
|
962
|
+
type: 'struct',
|
|
963
|
+
value: new Map([
|
|
964
|
+
['input', { type: 'value', value: { type: StringType, writable: true } }],
|
|
965
|
+
['out_a', { type: 'value', value: { type: StringType, writable: true } }],
|
|
966
|
+
['out_b', { type: 'value', value: { type: StringType, writable: true } }],
|
|
967
|
+
]),
|
|
968
|
+
};
|
|
969
|
+
const inputPath = [variant('field', 'input')];
|
|
970
|
+
const taskHashes = await createPackageWithTasks(testRepo, [
|
|
971
|
+
{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_a')] },
|
|
972
|
+
{ name: 'task-b', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_b')] },
|
|
973
|
+
], structure);
|
|
974
|
+
await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
|
|
975
|
+
await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
|
|
976
|
+
// First run: both tasks execute and capture input hashes
|
|
977
|
+
const capturedInputHashes = new Map();
|
|
978
|
+
for (const [name, hash] of taskHashes) {
|
|
979
|
+
mockRunner.setResult(hash, (inputHashesArr) => {
|
|
980
|
+
capturedInputHashes.set(name, [...inputHashesArr]);
|
|
981
|
+
return {
|
|
982
|
+
state: 'success',
|
|
983
|
+
cached: false,
|
|
984
|
+
outputHash: `output-${name}`,
|
|
985
|
+
};
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
const result1 = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
989
|
+
runner: mockRunner,
|
|
990
|
+
concurrency: 4,
|
|
991
|
+
});
|
|
992
|
+
assert.strictEqual(result1.success, true);
|
|
993
|
+
assert.strictEqual(result1.executed, 2);
|
|
994
|
+
// Write execution cache entries so the second run finds cached outputs.
|
|
995
|
+
// The orchestrator's stepPrepareTask checks the execution store.
|
|
996
|
+
// The executionId must be UUIDv7 format for LocalRefStore to find it.
|
|
997
|
+
const now = new Date();
|
|
998
|
+
const fakeUuid = '01900000-0000-7000-8000-000000000001';
|
|
999
|
+
for (const [name, hash] of taskHashes) {
|
|
1000
|
+
const captured = capturedInputHashes.get(name);
|
|
1001
|
+
assert.ok(captured, `Should have captured input hashes for ${name}`);
|
|
1002
|
+
const inHash = inputsHash(captured);
|
|
1003
|
+
await storage.refs.executionWrite(testRepo, hash, inHash, fakeUuid, variant('success', {
|
|
1004
|
+
executionId: fakeUuid,
|
|
1005
|
+
inputHashes: captured,
|
|
1006
|
+
outputHash: `output-${name}`,
|
|
1007
|
+
startedAt: now,
|
|
1008
|
+
completedAt: now,
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
// Second run: both tasks should be inline cache hits (workspace output matches)
|
|
1012
|
+
mockRunner.clearCalls();
|
|
1013
|
+
const result2 = await dataflowExecute(storage, testRepo, 'test-ws', {
|
|
1014
|
+
runner: mockRunner,
|
|
1015
|
+
concurrency: 4,
|
|
1016
|
+
});
|
|
1017
|
+
assert.strictEqual(result2.success, true);
|
|
1018
|
+
assert.strictEqual(result2.cached, 2);
|
|
1019
|
+
assert.strictEqual(result2.executed, 0);
|
|
1020
|
+
// MockRunner should not have been called — cache resolved inline
|
|
1021
|
+
assert.strictEqual(mockRunner.getCalls().length, 0);
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
//# sourceMappingURL=dataflow-orchestration.spec.js.map
|