@elaraai/e3-api-tests 1.0.83 → 1.0.85
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/README.md +13 -1
- package/dist/src/assertions.d.ts +15 -0
- package/dist/src/assertions.d.ts.map +1 -1
- package/dist/src/assertions.js +44 -1
- package/dist/src/assertions.js.map +1 -1
- package/dist/src/context.d.ts +29 -0
- package/dist/src/context.d.ts.map +1 -1
- package/dist/src/context.js +12 -9
- package/dist/src/context.js.map +1 -1
- package/dist/src/fixtures.d.ts +51 -4
- package/dist/src/fixtures.d.ts.map +1 -1
- package/dist/src/fixtures.js +78 -8
- package/dist/src/fixtures.js.map +1 -1
- package/dist/src/index.d.ts +12 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +28 -14
- package/dist/src/index.js.map +1 -1
- package/dist/src/suites/cli.d.ts.map +1 -1
- package/dist/src/suites/cli.js +19 -1
- package/dist/src/suites/cli.js.map +1 -1
- package/dist/src/suites/dataflow.d.ts.map +1 -1
- package/dist/src/suites/dataflow.js +347 -74
- package/dist/src/suites/dataflow.js.map +1 -1
- package/dist/src/suites/dataset-pages.js +4 -4
- package/dist/src/suites/dataset-pages.js.map +1 -1
- package/dist/src/suites/dataset-transfer.d.ts.map +1 -1
- package/dist/src/suites/dataset-transfer.js +115 -14
- package/dist/src/suites/dataset-transfer.js.map +1 -1
- package/dist/src/suites/functions.d.ts.map +1 -1
- package/dist/src/suites/functions.js +90 -20
- package/dist/src/suites/functions.js.map +1 -1
- package/dist/src/suites/package-transfer.d.ts.map +1 -1
- package/dist/src/suites/package-transfer.js +31 -3
- package/dist/src/suites/package-transfer.js.map +1 -1
- package/dist/src/suites/platform.d.ts.map +1 -1
- package/dist/src/suites/platform.js +7 -5
- package/dist/src/suites/platform.js.map +1 -1
- package/dist/src/suites/record-deploy.d.ts.map +1 -1
- package/dist/src/suites/record-deploy.js +16 -9
- package/dist/src/suites/record-deploy.js.map +1 -1
- package/dist/src/suites/records-keyed.d.ts.map +1 -1
- package/dist/src/suites/records-keyed.js +3 -2
- package/dist/src/suites/records-keyed.js.map +1 -1
- package/dist/src/suites/records.d.ts.map +1 -1
- package/dist/src/suites/records.js +40 -3
- package/dist/src/suites/records.js.map +1 -1
- package/dist/src/suites/workspaces.d.ts.map +1 -1
- package/dist/src/suites/workspaces.js +65 -1
- package/dist/src/suites/workspaces.js.map +1 -1
- package/package.json +14 -10
|
@@ -5,16 +5,35 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* Dataflow execution test suite.
|
|
7
7
|
*
|
|
8
|
-
* Tests: start, execute (blocking), poll for completion, logs
|
|
8
|
+
* Tests: start, execute (blocking), poll for completion, logs, and failures.
|
|
9
|
+
* A failing task fails in East, which every server runs: it is recorded
|
|
10
|
+
* `failed`, exit code 1, with its message in its stderr log. One case runs a
|
|
11
|
+
* command, a custom task's, which a server that runs no commands records
|
|
12
|
+
* `error` ({@link TestContext.commands}).
|
|
9
13
|
*/
|
|
10
14
|
import { describe, it } from 'node:test';
|
|
11
15
|
import assert from 'node:assert/strict';
|
|
12
16
|
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { IntegerType, StringType, encodeBeast2For, decodeBeast2For, variant } from '@elaraai/east';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
+
import { IntegerType, StringType, encodeBeast2For, decodeBeast2For, isValueOf, lessFor, variant } from '@elaraai/east';
|
|
18
|
+
import { TaskLogChunkType } from '@elaraai/e3-types';
|
|
19
|
+
import { packageImport, workspaceCreate, workspaceDeploy, workspaceStatus, dataflowExecuteLaunch, dataflowExecute, dataflowExecutePoll, dataflowEventsRemain, dataflowCancel, dataflowGraph, datasetSet, datasetGet, taskLogs, taskExecutionList, ApiError, } from '@elaraai/e3-api-client';
|
|
20
|
+
import { LOGGED_LINES, createPackageZip, createDiamondPackageZip, createFailingPackageZip, createLoggingPackageZip, createCommandPackageZip, createSlowPackageZip, createParallelMixedPackageZip, createFailingDiamondPackageZip, createWideParallelPackageZip, createSlowDiamondPackageZip, } from '../fixtures.js';
|
|
21
|
+
import { MALFORMED_WORKSPACE_NAMES, assertDataflowSucceeded, describeDataflowResult, refusedAsWorkspaceName } from '../assertions.js';
|
|
17
22
|
import { waitFor } from '../cli.js';
|
|
23
|
+
/**
|
|
24
|
+
* Asserts a call is refused `bad_request`, with the message the server gives.
|
|
25
|
+
*
|
|
26
|
+
* @param call - The refused call
|
|
27
|
+
* @param message - The server's message, whole
|
|
28
|
+
*/
|
|
29
|
+
async function rejectsBadRequest(call, message) {
|
|
30
|
+
await assert.rejects(call, (err) => {
|
|
31
|
+
assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
|
|
32
|
+
assert.strictEqual(err.code, 'bad_request');
|
|
33
|
+
assert.strictEqual(err.details, message);
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
18
37
|
/** Helper: import package, create workspace, deploy */
|
|
19
38
|
function withDeployed(setup, createZip, pkgName, wsName) {
|
|
20
39
|
return async (t) => {
|
|
@@ -55,6 +74,7 @@ export function dataflowTests(setup) {
|
|
|
55
74
|
const withSimpleExec = withDeployed(setup, createPackageZip, 'exec-pkg', 'exec-ws');
|
|
56
75
|
const withDiamond = withDeployed(setup, createDiamondPackageZip, 'diamond-pkg', 'diamond-ws');
|
|
57
76
|
const withFailing = withDeployed(setup, createFailingPackageZip, 'fail-pkg', 'fail-ws');
|
|
77
|
+
const withCommand = withDeployed(setup, createCommandPackageZip, 'cmd-pkg', 'cmd-ws');
|
|
58
78
|
const withMixed = withDeployed(setup, createParallelMixedPackageZip, 'mixed-pkg', 'mixed-ws');
|
|
59
79
|
const withFailingDiamond = withDeployed(setup, createFailingDiamondPackageZip, 'fdiamond-pkg', 'fdiamond-ws');
|
|
60
80
|
const withWideParallel = async (t) => {
|
|
@@ -90,8 +110,9 @@ export function dataflowTests(setup) {
|
|
|
90
110
|
const withNoExec = withDeployed(setup, createPackageZip, 'noexec-pkg', 'noexec-ws');
|
|
91
111
|
const withCache = withDeployed(setup, createPackageZip, 'cache-pkg', 'cache-ws');
|
|
92
112
|
const withFilter = withDeployed(setup, createDiamondPackageZip, 'filter-pkg', 'filter-ws');
|
|
113
|
+
const withForced = withDeployed(setup, createDiamondPackageZip, 'forced-pkg', 'forced-ws');
|
|
93
114
|
const withGraph = withDeployed(setup, createDiamondPackageZip, 'graph-pkg', 'graph-ws');
|
|
94
|
-
const withLogPag = withDeployed(setup,
|
|
115
|
+
const withLogPag = withDeployed(setup, createLoggingPackageZip, 'logpag-pkg', 'logpag-ws');
|
|
95
116
|
const withEvtPag = withDeployed(setup, createDiamondPackageZip, 'evtpag-pkg', 'evtpag-ws');
|
|
96
117
|
describe('dataflow', { concurrency: false }, () => {
|
|
97
118
|
describe('simple execution', { concurrency: false }, () => {
|
|
@@ -115,21 +136,39 @@ export function dataflowTests(setup) {
|
|
|
115
136
|
assert.ok(outputDataset, 'Output dataset .tasks.compute.output should exist');
|
|
116
137
|
assert.strictEqual(outputDataset.status.type, 'up-to-date');
|
|
117
138
|
});
|
|
118
|
-
it('taskExecutionList lists the latest
|
|
139
|
+
it('taskExecutionList lists a task\'s runs the latest first, a page at a time, each naming its execution', async (t) => {
|
|
140
|
+
const ctx = await withSimpleExec(t);
|
|
141
|
+
const opts = await ctx.opts();
|
|
142
|
+
const list = (page = {}) => taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', opts, page);
|
|
143
|
+
// Three forced runs of the same inputs: three runs under one inputs hash.
|
|
144
|
+
for (let run = 0; run < 3; run++) {
|
|
145
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'exec-ws', { force: true }, opts));
|
|
146
|
+
}
|
|
147
|
+
const every = await list();
|
|
148
|
+
assert.strictEqual(every.length, 3, 'every run is listed');
|
|
149
|
+
assert.ok(every.every(e => e.inputsHash === every[0].inputsHash && e.status.type === 'success'), 'all runs share the inputs hash');
|
|
150
|
+
const ids = every.map(e => e.executionId);
|
|
151
|
+
assert.strictEqual(new Set(ids).size, ids.length, 'each run names its own execution');
|
|
152
|
+
assert.deepStrictEqual(ids, [...ids].sort().reverse(), 'the latest first');
|
|
153
|
+
// A page at a time, each from before the last run of the page before
|
|
154
|
+
const first = await list({ limit: 2 });
|
|
155
|
+
assert.deepStrictEqual(first.map(e => e.executionId), ids.slice(0, 2));
|
|
156
|
+
const next = await list({ limit: 2, before: first[1].executionId });
|
|
157
|
+
assert.deepStrictEqual(next.map(e => e.executionId), ids.slice(2), 'the next page holds the runs before it');
|
|
158
|
+
assert.deepStrictEqual(await list({ before: ids[2] }), [], 'and the page after it is empty');
|
|
159
|
+
});
|
|
160
|
+
it('refuses a page of a task\'s runs whose limit is not a positive integer, or whose cursor is no execution\'s id', async (t) => {
|
|
119
161
|
const ctx = await withSimpleExec(t);
|
|
120
162
|
const opts = await ctx.opts();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
assert.strictEqual(new Set(every.map(e => e.startedAt)).size, every.length, 'attempts are distinct runs');
|
|
131
|
-
const newest = every.map(e => e.startedAt).sort().at(-1);
|
|
132
|
-
assert.strictEqual(latest[0].startedAt, newest, 'the default item is the newest attempt');
|
|
163
|
+
const list = (page) => taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', opts, page);
|
|
164
|
+
await rejectsBadRequest(list({ limit: 0 }), 'limit must be a positive integer, got "0"');
|
|
165
|
+
await rejectsBadRequest(list({ limit: 1.5 }), 'limit must be a positive integer, got "1.5"');
|
|
166
|
+
await assert.rejects(list({ before: 'not-an-id' }), (err) => {
|
|
167
|
+
assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
|
|
168
|
+
assert.strictEqual(err.code, 'invalid_name');
|
|
169
|
+
assert.strictEqual(err.details?.kind, 'execution id');
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
133
172
|
});
|
|
134
173
|
it('dataflowExecuteLaunch triggers execution (non-blocking)', async (t) => {
|
|
135
174
|
const ctx = await withSimpleExec(t);
|
|
@@ -185,12 +224,33 @@ export function dataflowTests(setup) {
|
|
|
185
224
|
await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'exec-ws', { force: true }, opts);
|
|
186
225
|
// Get logs
|
|
187
226
|
const logs = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'exec-ws', 'compute', { stream: 'stdout' }, opts);
|
|
188
|
-
//
|
|
189
|
-
assert.ok(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
227
|
+
// A chunk of its log, which may be empty for a simple task
|
|
228
|
+
assert.ok(isValueOf(logs, TaskLogChunkType), 'a TaskLogChunk');
|
|
229
|
+
});
|
|
230
|
+
it('taskLogs names the execution it reads, and whether it has ended, and reads the one a request names', async (t) => {
|
|
231
|
+
const ctx = await withLogPag(t);
|
|
232
|
+
const opts = await ctx.opts();
|
|
233
|
+
const read = (logOptions = {}) => taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', { stream: 'stdout', ...logOptions }, opts);
|
|
234
|
+
const latest = async () => (await taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', opts, { limit: 1 }))[0];
|
|
235
|
+
// A run's log names the run, which has ended
|
|
236
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
|
|
237
|
+
const first = await latest();
|
|
238
|
+
const logs = await read();
|
|
239
|
+
assert.deepStrictEqual([logs.inputsHash, logs.executionId, logs.ended], [first.inputsHash, first.executionId, true]);
|
|
240
|
+
// Another run is the task's current execution, and a request naming
|
|
241
|
+
// the first still reads the first
|
|
242
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
|
|
243
|
+
const second = await latest();
|
|
244
|
+
assert.notStrictEqual(second.executionId, first.executionId, 'the run is another execution');
|
|
245
|
+
assert.strictEqual((await read()).executionId, second.executionId);
|
|
246
|
+
const named = await read({ offset: 5, execution: { inputsHash: first.inputsHash, executionId: first.executionId } });
|
|
247
|
+
assert.deepStrictEqual([named.executionId, named.data], [first.executionId, LOGGED_LINES.slice(5)]);
|
|
248
|
+
// An execution the task does not record is none
|
|
249
|
+
await assert.rejects(read({ execution: { inputsHash: first.inputsHash, executionId: '0190a0b0-4444-7000-8000-000000000000' } }), (err) => {
|
|
250
|
+
assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
|
|
251
|
+
assert.strictEqual(err.code, 'execution_not_found');
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
194
254
|
});
|
|
195
255
|
});
|
|
196
256
|
describe('diamond dependency execution', { concurrency: false }, () => {
|
|
@@ -234,15 +294,18 @@ export function dataflowTests(setup) {
|
|
|
234
294
|
const opts = await ctx.opts();
|
|
235
295
|
// Start execution
|
|
236
296
|
await dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'diamond-ws', { force: true }, opts);
|
|
237
|
-
// Poll and collect events
|
|
297
|
+
// Poll and collect events, each poll from the cursor the last gave
|
|
238
298
|
const events = [];
|
|
299
|
+
let since = 0n;
|
|
239
300
|
const maxWait = 60000;
|
|
240
301
|
const startTime = Date.now();
|
|
241
302
|
while (Date.now() - startTime < maxWait) {
|
|
242
|
-
const state = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'diamond-ws', {
|
|
303
|
+
const state = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'diamond-ws', { since }, opts);
|
|
243
304
|
// Collect new events
|
|
244
305
|
events.push(...state.events);
|
|
245
|
-
|
|
306
|
+
since = state.nextSeq;
|
|
307
|
+
// The run's end is its last word once no event of it is left
|
|
308
|
+
if ((state.status.type === 'completed' || state.status.type === 'failed') && !dataflowEventsRemain(state)) {
|
|
246
309
|
break;
|
|
247
310
|
}
|
|
248
311
|
await new Promise(r => setTimeout(r, 100));
|
|
@@ -265,7 +328,8 @@ export function dataflowTests(setup) {
|
|
|
265
328
|
assert.strictEqual(result.executed, 0n);
|
|
266
329
|
assert.strictEqual(result.tasks.length, 1);
|
|
267
330
|
assert.strictEqual(result.tasks[0].name, 'failing');
|
|
268
|
-
|
|
331
|
+
// The body's own failure: its runner records it, and exits 1
|
|
332
|
+
assert.deepStrictEqual(result.tasks[0].state, variant('failed', { exitCode: 1n }));
|
|
269
333
|
});
|
|
270
334
|
it('dataflowExecutePoll shows failed status after task failure', async (t) => {
|
|
271
335
|
const ctx = await withFailing(t);
|
|
@@ -304,6 +368,33 @@ export function dataflowTests(setup) {
|
|
|
304
368
|
assert.strictEqual(result2.failed, 1n);
|
|
305
369
|
});
|
|
306
370
|
});
|
|
371
|
+
// The suites' one command: a custom task's. Every other task is East,
|
|
372
|
+
// which every server runs; a server that runs no commands — e3 in a page —
|
|
373
|
+
// records the task error, saying so.
|
|
374
|
+
describe('command task', { concurrency: false }, () => {
|
|
375
|
+
it('records a failing command failed, or error on a server that runs no commands', async (t) => {
|
|
376
|
+
const ctx = await withCommand(t);
|
|
377
|
+
const opts = await ctx.opts();
|
|
378
|
+
const result = await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'cmd-ws', { force: true }, opts);
|
|
379
|
+
assert.strictEqual(result.success, false);
|
|
380
|
+
assert.strictEqual(result.failed, 1n);
|
|
381
|
+
assert.strictEqual(result.executed, 0n);
|
|
382
|
+
assert.strictEqual(result.tasks.length, 1);
|
|
383
|
+
const task = result.tasks[0];
|
|
384
|
+
assert.strictEqual(task.name, 'command');
|
|
385
|
+
if (ctx.commands) {
|
|
386
|
+
// The command ran, and exited 1
|
|
387
|
+
assert.deepStrictEqual(task.state, variant('failed', { exitCode: 1n }), describeDataflowResult(result));
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
// Nothing ran, and the server says why
|
|
391
|
+
if (task.state.type !== 'error') {
|
|
392
|
+
assert.fail(`expected the command task recorded error on a server that runs no commands\n${describeDataflowResult(result)}`);
|
|
393
|
+
}
|
|
394
|
+
assert.match(task.state.value.message, /runs no commands/);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
307
398
|
describe('parallel task failures', { concurrency: false }, () => {
|
|
308
399
|
describe('mixed success/failure', { concurrency: false }, () => {
|
|
309
400
|
it('parallel tasks with mixed success/failure complete without stalling', async (t) => {
|
|
@@ -313,10 +404,10 @@ export function dataflowTests(setup) {
|
|
|
313
404
|
// Dataflow should complete (not stall) and report failure
|
|
314
405
|
assert.strictEqual(result.success, false);
|
|
315
406
|
assert.strictEqual(result.failed, 1n);
|
|
316
|
-
// The failing task must be reported
|
|
407
|
+
// The failing task must be reported: its body's failure, exit code 1
|
|
317
408
|
const failC = result.tasks.find(t => t.name === 'fail_c');
|
|
318
409
|
assert.ok(failC, 'fail_c task should be in results');
|
|
319
|
-
assert.
|
|
410
|
+
assert.deepStrictEqual(failC.state, variant('failed', { exitCode: 1n }));
|
|
320
411
|
// Tasks that did execute should have succeeded
|
|
321
412
|
for (const task of result.tasks) {
|
|
322
413
|
if (task.name !== 'fail_c') {
|
|
@@ -331,18 +422,24 @@ export function dataflowTests(setup) {
|
|
|
331
422
|
await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', { force: true }, opts);
|
|
332
423
|
// taskLogs for failed task should NOT throw
|
|
333
424
|
const logs = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', 'fail_c', { stream: 'stderr' }, opts);
|
|
334
|
-
assert.ok(
|
|
335
|
-
|
|
425
|
+
assert.ok(isValueOf(logs, TaskLogChunkType), 'a TaskLogChunk');
|
|
426
|
+
// Its stderr holds the failure's message, which its body made from
|
|
427
|
+
// its input as it ran
|
|
428
|
+
assert.match(logs.data, /fail_c fails, given 3/);
|
|
336
429
|
});
|
|
337
430
|
it('workspace status reflects failed tasks correctly', async (t) => {
|
|
338
431
|
const ctx = await withMixed(t);
|
|
339
432
|
const opts = await ctx.opts();
|
|
340
433
|
await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', { force: true }, opts);
|
|
341
434
|
const status = await workspaceStatus(ctx.config.baseUrl, ctx.repoName, 'mixed-ws', opts);
|
|
342
|
-
// Failed task should show 'failed' status, not stuck as 'in-progress'
|
|
435
|
+
// Failed task should show 'failed' status, not stuck as 'in-progress',
|
|
436
|
+
// with the exit code its runner gave its body's failure
|
|
343
437
|
const failedTask = status.tasks.find(t => t.name === 'fail_c');
|
|
344
438
|
assert.ok(failedTask, 'fail_c task should be in workspace status');
|
|
345
|
-
|
|
439
|
+
if (failedTask.status.type !== 'failed') {
|
|
440
|
+
assert.fail(`Failed task should have failed status, got ${failedTask.status.type}`);
|
|
441
|
+
}
|
|
442
|
+
assert.strictEqual(failedTask.status.value.exitCode, 1n);
|
|
346
443
|
// No task should be stuck as 'in-progress'
|
|
347
444
|
for (const task of status.tasks) {
|
|
348
445
|
assert.notStrictEqual(task.status.type, 'in-progress', `Task ${task.name} should not be stuck in-progress`);
|
|
@@ -365,7 +462,7 @@ export function dataflowTests(setup) {
|
|
|
365
462
|
assert.ok(rightTask, 'right task should be in results');
|
|
366
463
|
assert.ok(mergeTask, 'merge task should be in results');
|
|
367
464
|
assert.strictEqual(leftTask.state.type, 'success');
|
|
368
|
-
assert.
|
|
465
|
+
assert.deepStrictEqual(rightTask.state, variant('failed', { exitCode: 1n }));
|
|
369
466
|
assert.strictEqual(mergeTask.state.type, 'skipped');
|
|
370
467
|
});
|
|
371
468
|
it('taskLogs returns execution_not_found for skipped task', async (t) => {
|
|
@@ -461,18 +558,47 @@ export function dataflowTests(setup) {
|
|
|
461
558
|
}, 60000);
|
|
462
559
|
assert.strictEqual(finalStatus, 'aborted');
|
|
463
560
|
});
|
|
561
|
+
it('serves why a cancelled run\'s task stopped, in the task\'s history and its status', async (t) => {
|
|
562
|
+
const ctx = await withSlow(t);
|
|
563
|
+
const opts = await ctx.opts();
|
|
564
|
+
const history = () => taskExecutionList(ctx.config.baseUrl, ctx.repoName, 'slow-ws', 'slow', opts);
|
|
565
|
+
await dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'slow-ws', { force: true }, opts);
|
|
566
|
+
// The task's attempt is recorded running before the cancel, so the
|
|
567
|
+
// cancel stops that attempt. Its log says it has not ended.
|
|
568
|
+
await waitFor(async () => (await history()).some((item) => item.status.type === 'running'), 60000);
|
|
569
|
+
const live = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'slow-ws', 'slow', { stream: 'stderr' }, opts);
|
|
570
|
+
assert.strictEqual(live.ended, false, 'the log of an execution still running has not ended');
|
|
571
|
+
await dataflowCancel(ctx.config.baseUrl, ctx.repoName, 'slow-ws', opts);
|
|
572
|
+
const cancelled = (items) => items.find((item) => item.status.type === 'cancelled');
|
|
573
|
+
await waitFor(async () => cancelled(await history()) !== undefined, 60000);
|
|
574
|
+
const stopped = cancelled(await history());
|
|
575
|
+
if (stopped?.reason.type !== 'some')
|
|
576
|
+
assert.fail('a cancelled attempt says why it stopped');
|
|
577
|
+
assert.strictEqual(stopped.reason.value.kind.type, 'aborted');
|
|
578
|
+
assert.notStrictEqual(stopped.reason.value.message, '', 'in words, as its log\'s last line does');
|
|
579
|
+
const ended = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'slow-ws', 'slow', {
|
|
580
|
+
stream: 'stderr', execution: { inputsHash: live.inputsHash, executionId: live.executionId },
|
|
581
|
+
}, opts);
|
|
582
|
+
assert.strictEqual(ended.ended, true, 'and once it is cancelled, it has');
|
|
583
|
+
// The task reads ready, naming why its latest attempt stopped.
|
|
584
|
+
const task = (await workspaceStatus(ctx.config.baseUrl, ctx.repoName, 'slow-ws', opts)).tasks.find((each) => each.name === 'slow');
|
|
585
|
+
assert.ok(task !== undefined, 'the task is in the status');
|
|
586
|
+
assert.strictEqual(task.status.type, 'ready');
|
|
587
|
+
if (task.stopped.type !== 'some')
|
|
588
|
+
assert.fail('the status names why the task\'s attempt stopped');
|
|
589
|
+
assert.strictEqual(task.stopped.value.kind.type, 'aborted');
|
|
590
|
+
});
|
|
464
591
|
it('dataflowCancel returns error when no execution is running', async (t) => {
|
|
465
592
|
const ctx = await withSlow(t);
|
|
466
593
|
const opts = await ctx.opts();
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
assert.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
594
|
+
// Nothing is running to cancel: the server says so, as the request
|
|
595
|
+
// meeting the workspace's state rather than a fault of its own
|
|
596
|
+
await assert.rejects(dataflowCancel(ctx.config.baseUrl, ctx.repoName, 'slow-ws', opts), (err) => {
|
|
597
|
+
assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
|
|
598
|
+
assert.strictEqual(err.code, 'dataflow_error');
|
|
599
|
+
assert.strictEqual(err.details?.message, 'No active execution for this workspace');
|
|
600
|
+
return true;
|
|
601
|
+
});
|
|
476
602
|
});
|
|
477
603
|
});
|
|
478
604
|
// Concurrent set during execution - tests that datasetSet is not blocked by dataflowExecuteLaunch
|
|
@@ -627,6 +753,17 @@ export function dataflowTests(setup) {
|
|
|
627
753
|
assert.ok(err.code === 'workspace_not_found' || err.code === 'workspace_not_deployed', `Expected workspace_not_found or workspace_not_deployed, got ${err.code}`);
|
|
628
754
|
}
|
|
629
755
|
});
|
|
756
|
+
it('dataflowExecuteLaunch refuses a name no workspace can have as invalid_name of a workspace, before it takes a lock', async (t) => {
|
|
757
|
+
const ctx = await setup(t);
|
|
758
|
+
const opts = await ctx.opts();
|
|
759
|
+
// Each is refused as a workspace's name, whatever orchestrator runs
|
|
760
|
+
// the repository's dataflows: `bad:name` not as a lock's, and
|
|
761
|
+
// `main#dataflow` without taking the lock a run of main's dataflow
|
|
762
|
+
// holds.
|
|
763
|
+
for (const [name, why] of MALFORMED_WORKSPACE_NAMES) {
|
|
764
|
+
await assert.rejects(dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, name, { force: true }, opts), refusedAsWorkspaceName(name, why));
|
|
765
|
+
}
|
|
766
|
+
});
|
|
630
767
|
it('dataflowGraph returns error for non-existent workspace', async (t) => {
|
|
631
768
|
const ctx = await setup(t);
|
|
632
769
|
const opts = await ctx.opts();
|
|
@@ -701,6 +838,42 @@ export function dataflowTests(setup) {
|
|
|
701
838
|
}
|
|
702
839
|
});
|
|
703
840
|
});
|
|
841
|
+
// A run forces every task, none, or the tasks it names. The diamond's left
|
|
842
|
+
// and right read the inputs, and merge reads both.
|
|
843
|
+
describe('forcing named tasks', { concurrency: false }, () => {
|
|
844
|
+
it('re-runs the tasks a run names, and serves the rest from the cache while their inputs hold', async (t) => {
|
|
845
|
+
const ctx = await withForced(t);
|
|
846
|
+
const opts = await ctx.opts();
|
|
847
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'forced-ws', {}, opts), 'first run');
|
|
848
|
+
const result = await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'forced-ws', { force: ['left'] }, opts);
|
|
849
|
+
assertDataflowSucceeded(result, 'forced run');
|
|
850
|
+
// left runs again and writes what it wrote before, so merge, which
|
|
851
|
+
// reads it, is served from the cache as right is.
|
|
852
|
+
assert.deepStrictEqual(['left', 'right', 'merge'].map((name) => [name, result.tasks.find((task) => task.name === name)?.cached]), [['left', false], ['right', true], ['merge', true]], describeDataflowResult(result));
|
|
853
|
+
});
|
|
854
|
+
it('refuses a start forcing a task the graph lacks, or one the filter leaves out, before anything runs', async (t) => {
|
|
855
|
+
const ctx = await withForced(t);
|
|
856
|
+
const opts = await ctx.opts();
|
|
857
|
+
await assert.rejects(dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'forced-ws', { force: ['left', 'no_such_task'] }, opts), (err) => {
|
|
858
|
+
assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
|
|
859
|
+
assert.strictEqual(err.code, 'task_not_found');
|
|
860
|
+
assert.strictEqual(err.details?.task, 'no_such_task');
|
|
861
|
+
return true;
|
|
862
|
+
});
|
|
863
|
+
await assert.rejects(dataflowExecuteLaunch(ctx.config.baseUrl, ctx.repoName, 'forced-ws', { force: ['right'], filter: 'left' }, opts), (err) => {
|
|
864
|
+
assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
|
|
865
|
+
assert.strictEqual(err.code, 'dataflow_error');
|
|
866
|
+
assert.strictEqual(err.details?.message, "the run forces 'right', which the filter 'left' leaves out: a filtered run runs 'left' and the tasks it depends on, and no other");
|
|
867
|
+
return true;
|
|
868
|
+
});
|
|
869
|
+
// Neither started a run.
|
|
870
|
+
await assert.rejects(dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'forced-ws', {}, opts), (err) => {
|
|
871
|
+
assert.ok(err instanceof ApiError, `Expected ApiError, got ${String(err)}`);
|
|
872
|
+
assert.strictEqual(err.code, 'execution_not_found');
|
|
873
|
+
return true;
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
});
|
|
704
877
|
describe('dependency graph', { concurrency: false }, () => {
|
|
705
878
|
it('dataflowGraph returns correct structure', async (t) => {
|
|
706
879
|
const ctx = await withGraph(t);
|
|
@@ -727,43 +900,143 @@ export function dataflowTests(setup) {
|
|
|
727
900
|
it('taskLogs supports offset and limit', async (t) => {
|
|
728
901
|
const ctx = await withLogPag(t);
|
|
729
902
|
const opts = await ctx.opts();
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
903
|
+
const read = (logOptions) => taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', { stream: 'stdout', ...logOptions }, opts);
|
|
904
|
+
const total = BigInt(LOGGED_LINES.length);
|
|
905
|
+
// The task writes three known lines to its stdout
|
|
906
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
|
|
907
|
+
// The whole log
|
|
908
|
+
const full = await read({});
|
|
909
|
+
assert.strictEqual(full.data, LOGGED_LINES);
|
|
910
|
+
assert.strictEqual(full.offset, 0n);
|
|
911
|
+
assert.strictEqual(full.size, total);
|
|
912
|
+
assert.strictEqual(full.totalSize, total);
|
|
913
|
+
assert.strictEqual(full.complete, true);
|
|
914
|
+
// Its first 5 bytes: a limit cuts it, short of its end
|
|
915
|
+
const head = await read({ offset: 0, limit: 5 });
|
|
916
|
+
assert.strictEqual(head.data, 'line ');
|
|
917
|
+
assert.strictEqual(head.offset, 0n);
|
|
918
|
+
assert.strictEqual(head.size, 5n);
|
|
919
|
+
assert.strictEqual(head.totalSize, total);
|
|
920
|
+
assert.strictEqual(head.complete, false);
|
|
921
|
+
// A window from byte 5: an offset and a limit together
|
|
922
|
+
const middle = await read({ offset: 5, limit: 9 });
|
|
923
|
+
assert.strictEqual(middle.data, '0\nline 1\n');
|
|
924
|
+
assert.strictEqual(middle.offset, 5n);
|
|
925
|
+
assert.strictEqual(middle.size, 9n);
|
|
926
|
+
assert.strictEqual(middle.complete, false);
|
|
927
|
+
// From byte 14 to its end: an offset alone
|
|
928
|
+
const tail = await read({ offset: 14 });
|
|
929
|
+
assert.strictEqual(tail.data, 'line 2\n');
|
|
930
|
+
assert.strictEqual(tail.offset, 14n);
|
|
931
|
+
assert.strictEqual(tail.size, 7n);
|
|
932
|
+
assert.strictEqual(tail.complete, true);
|
|
933
|
+
// A window of no bytes is a window: it reports the log's size, which
|
|
934
|
+
// is how e3's CLI learns it
|
|
935
|
+
const probe = await read({ offset: 0, limit: 0 });
|
|
936
|
+
assert.strictEqual(probe.data, '');
|
|
937
|
+
assert.strictEqual(probe.size, 0n);
|
|
938
|
+
assert.strictEqual(probe.totalSize, total);
|
|
939
|
+
});
|
|
940
|
+
it('refuses a malformed window or stream with bad_request, before it reads a log', async (t) => {
|
|
941
|
+
const ctx = await withLogPag(t);
|
|
942
|
+
const opts = await ctx.opts();
|
|
943
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', { force: true }, opts));
|
|
944
|
+
// Through the client, which sends a number as it prints
|
|
945
|
+
const windows = [
|
|
946
|
+
[{ offset: NaN }, 'offset must be a non-negative integer, got "NaN"'],
|
|
947
|
+
[{ offset: -1 }, 'offset must be a non-negative integer, got "-1"'],
|
|
948
|
+
[{ offset: 1.5 }, 'offset must be a non-negative integer, got "1.5"'],
|
|
949
|
+
[{ limit: NaN }, 'limit must be a non-negative integer, got "NaN"'],
|
|
950
|
+
[{ limit: -1 }, 'limit must be a non-negative integer, got "-1"'],
|
|
951
|
+
];
|
|
952
|
+
for (const [window, message] of windows) {
|
|
953
|
+
await rejectsBadRequest(taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'log', { stream: 'stdout', ...window }, opts), message);
|
|
954
|
+
}
|
|
955
|
+
// As a request spells them: a word; a number past those a number holds
|
|
956
|
+
// exactly; a stream other than the two a log has, a way out of the
|
|
957
|
+
// execution among them; and a window for a task there is no log of,
|
|
958
|
+
// refused before the server looks for one
|
|
959
|
+
const logs = `${ctx.config.baseUrl}/api/repos/${encodeURIComponent(ctx.repoName)}/workspaces/logpag-ws/dataflow/logs`;
|
|
960
|
+
const headers = opts.token ? { 'Authorization': `Bearer ${opts.token}` } : {};
|
|
961
|
+
const requests = [
|
|
962
|
+
['log?offset=abc', 'offset must be a non-negative integer, got "abc"'],
|
|
963
|
+
['log?limit=abc', 'limit must be a non-negative integer, got "abc"'],
|
|
964
|
+
['log?offset=99999999999999999999', 'offset must be at most 9007199254740991, got "99999999999999999999"'],
|
|
965
|
+
['log?stream=stdin', 'stream must be stdout or stderr, got "stdin"'],
|
|
966
|
+
[`log?stream=${encodeURIComponent('../../stdout')}`, 'stream must be stdout or stderr, got "../../stdout"'],
|
|
967
|
+
['no_such_task?offset=-1', 'offset must be a non-negative integer, got "-1"'],
|
|
968
|
+
[`log?inputs=${'a'.repeat(64)}`, 'inputs and execution name an execution together: give both, or neither'],
|
|
969
|
+
['log?execution=0190a0b0-4444-7000-8000-000000000000', 'inputs and execution name an execution together: give both, or neither'],
|
|
970
|
+
];
|
|
971
|
+
for (const [request, message] of requests) {
|
|
972
|
+
const response = await ctx.fetch(`${logs}/${request}`, { headers });
|
|
973
|
+
assert.strictEqual(response.status, 400, request);
|
|
974
|
+
assert.deepStrictEqual(await response.json(), { error: { type: 'bad_request', message } }, request);
|
|
737
975
|
}
|
|
738
|
-
// Get first 5 bytes
|
|
739
|
-
const chunk1 = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'compute', { stream: 'stdout', offset: 0, limit: 5 }, opts);
|
|
740
|
-
assert.strictEqual(chunk1.data, full.data.slice(0, 5));
|
|
741
|
-
assert.strictEqual(chunk1.offset, 0n);
|
|
742
|
-
// Get from byte 5 onwards
|
|
743
|
-
const chunk2 = await taskLogs(ctx.config.baseUrl, ctx.repoName, 'logpag-ws', 'compute', { stream: 'stdout', offset: 5 }, opts);
|
|
744
|
-
assert.strictEqual(chunk2.offset, 5n);
|
|
745
|
-
assert.strictEqual(chunk2.data, full.data.slice(5));
|
|
746
976
|
});
|
|
747
977
|
});
|
|
748
978
|
describe('event pagination', { concurrency: false }, () => {
|
|
749
|
-
it('dataflowExecutePoll
|
|
979
|
+
it('dataflowExecutePoll serves the events past its cursor, at most its limit, and the cursor past them', async (t) => {
|
|
750
980
|
const ctx = await withEvtPag(t);
|
|
751
981
|
const opts = await ctx.opts();
|
|
982
|
+
const poll = (stateOptions) => dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', stateOptions, opts);
|
|
752
983
|
// Execute and wait for completion
|
|
753
984
|
await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { force: true }, opts);
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
assert.ok(
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
assert.
|
|
985
|
+
const before = lessFor(IntegerType);
|
|
986
|
+
// Every event, and the cursor past the last
|
|
987
|
+
const all = await poll({});
|
|
988
|
+
assert.ok(all.events.length >= 3, `Expected at least 3 events, got ${all.events.length}`);
|
|
989
|
+
assert.ok(before(0n, all.nextSeq), 'the cursor moved past the events');
|
|
990
|
+
// A page of one, and the next from its cursor: the events in order
|
|
991
|
+
const page1 = await poll({ limit: 1 });
|
|
992
|
+
const page2 = await poll({ since: page1.nextSeq, limit: 1 });
|
|
993
|
+
assert.deepStrictEqual([...page1.events, ...page2.events], all.events.slice(0, 2), 'the first two events, in order');
|
|
994
|
+
assert.ok(before(page1.nextSeq, page2.nextSeq), 'each cursor past the events served');
|
|
995
|
+
// A poll that has every event is served none, and its cursor stays
|
|
996
|
+
const caughtUp = await poll({ since: all.nextSeq });
|
|
997
|
+
assert.deepStrictEqual([caughtUp.events, caughtUp.nextSeq], [[], all.nextSeq]);
|
|
998
|
+
assert.strictEqual(caughtUp.status.type, 'completed', 'with the run\'s state');
|
|
999
|
+
// Where the run's events end: a poll of every event reaches its last,
|
|
1000
|
+
// and a page of one leaves events for the next
|
|
1001
|
+
assert.strictEqual(all.lastSeq, all.nextSeq, 'a poll of every event is at the run\'s last');
|
|
1002
|
+
assert.deepStrictEqual([dataflowEventsRemain(all), dataflowEventsRemain(page1), dataflowEventsRemain(caughtUp)], [false, true, false]);
|
|
1003
|
+
// Each poll names the run, and another run is another id
|
|
1004
|
+
assert.deepStrictEqual([page1.runId, page2.runId, caughtUp.runId], [all.runId, all.runId, all.runId]);
|
|
1005
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { force: true }, opts));
|
|
1006
|
+
assert.notStrictEqual((await poll({ limit: 0 })).runId, all.runId, 'the next run is another');
|
|
1007
|
+
});
|
|
1008
|
+
it('refuses a malformed cursor or limit with bad_request; a poll of no events carries the run\'s state', async (t) => {
|
|
1009
|
+
const ctx = await withEvtPag(t);
|
|
1010
|
+
const opts = await ctx.opts();
|
|
1011
|
+
assertDataflowSucceeded(await dataflowExecute(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { force: true }, opts));
|
|
1012
|
+
// Through the client, which sends a limit as it prints
|
|
1013
|
+
const windows = [
|
|
1014
|
+
[{ limit: NaN }, 'limit must be a non-negative integer, got "NaN"'],
|
|
1015
|
+
[{ limit: -1 }, 'limit must be a non-negative integer, got "-1"'],
|
|
1016
|
+
[{ limit: 1.5 }, 'limit must be a non-negative integer, got "1.5"'],
|
|
1017
|
+
[{ since: -1n }, 'since must be a non-negative integer, got "-1"'],
|
|
1018
|
+
];
|
|
1019
|
+
for (const [window, message] of windows) {
|
|
1020
|
+
await rejectsBadRequest(dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', window, opts), message);
|
|
1021
|
+
}
|
|
1022
|
+
// As a request spells them: a word, and a number past those a number
|
|
1023
|
+
// holds exactly
|
|
1024
|
+
const execution = `${ctx.config.baseUrl}/api/repos/${encodeURIComponent(ctx.repoName)}/workspaces/evtpag-ws/dataflow/execution`;
|
|
1025
|
+
const headers = opts.token ? { 'Authorization': `Bearer ${opts.token}` } : {};
|
|
1026
|
+
const requests = [
|
|
1027
|
+
['since=abc', 'since must be a non-negative integer, got "abc"'],
|
|
1028
|
+
['since=1.5', 'since must be a non-negative integer, got "1.5"'],
|
|
1029
|
+
['since=99999999999999999999', 'since must be at most 9007199254740991, got "99999999999999999999"'],
|
|
1030
|
+
];
|
|
1031
|
+
for (const [query, message] of requests) {
|
|
1032
|
+
const response = await ctx.fetch(`${execution}?${query}`, { headers });
|
|
1033
|
+
assert.strictEqual(response.status, 400, query);
|
|
1034
|
+
assert.deepStrictEqual(await response.json(), { error: { type: 'bad_request', message } }, query);
|
|
1035
|
+
}
|
|
1036
|
+
const none = await dataflowExecutePoll(ctx.config.baseUrl, ctx.repoName, 'evtpag-ws', { limit: 0 }, opts);
|
|
1037
|
+
assert.strictEqual(none.status.type, 'completed');
|
|
1038
|
+
assert.deepStrictEqual([none.events, none.nextSeq], [[], 0n], 'no events, and the cursor where it was');
|
|
1039
|
+
assert.ok(dataflowEventsRemain(none), 'and where the run\'s events end, past the cursor');
|
|
767
1040
|
});
|
|
768
1041
|
});
|
|
769
1042
|
});
|